Skip to content

Commit 113235a

Browse files
authored
Merge pull request #113 from qianmoQ/dev-26.3.0
Refactor app components into composables for better organization
2 parents bae4515 + ebed480 commit 113235a

7 files changed

Lines changed: 298 additions & 191 deletions

File tree

.github/workflows/docs.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ jobs:
3535
cache-dependency-path: docs/pnpm-lock.yaml
3636

3737
- name: Install dependencies
38-
run: pnpm install --frozen-lockfile
38+
# 用 --no-frozen-lockfile:docs 为独立子项目,pnpm 在 CI 中会向上读到根
39+
# package.json 的 overrides 导致 frozen 校验失败;文档站构建无需锁定一致性。
40+
run: pnpm install --no-frozen-lockfile
3941

4042
- name: Build
4143
run: pnpm build

src/App.vue

Lines changed: 20 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,11 @@ import {useWorkspace} from './composables/useWorkspace'
535535
import {useTextCommands} from './composables/useTextCommands'
536536
import {useGitPermalink} from './composables/useGitPermalink'
537537
import {useRevealInTree} from './composables/useRevealInTree'
538+
import {useWorkspaceRoots} from './composables/useWorkspaceRoots'
539+
import {useGitStatus} from './composables/useGitStatus'
540+
import {useSessionTabs} from './composables/useSessionTabs'
541+
import {useEditorContextMenu} from './composables/useEditorContextMenu'
542+
import {useRunConfig} from './composables/useRunConfig'
538543
import EditorTabs from './components/EditorTabs.vue'
539544
import IndentControl from './components/IndentControl.vue'
540545
import Sidebar from './components/Sidebar.vue'
@@ -732,6 +737,9 @@ const rootDir = ref<string | null>(null)
732737
733738
// 远程仓库永久链接(复制 / 在浏览器打开)
734739
const {copyPermalink, openPermalink} = useGitPermalink(rootDir, currentFilePath, cursorInfo)
740+
741+
// 多根工作区:额外挂载的文件夹(Git/搜索仍走主根 rootDir)
742+
const {extraRoots, addWorkspaceFolder, removeWorkspaceFolder, resetExtraRoots} = useWorkspaceRoots(rootDir)
735743
const sidebarVisible = ref(kvGet('sidebar-visible') === 'true')
736744
// 专注模式:隐藏顶部工具栏/运行输入/侧栏/状态栏,沉浸编辑
737745
const zenMode = ref(false)
@@ -767,72 +775,11 @@ const openFolderPath = (path: string) => {
767775
sidebarVisible.value = true
768776
rememberFolder(path)
769777
// 打开新文件夹视为新工作区,清空额外挂载的根
770-
extraRoots.value = []
771-
kvSetJSON(WORKSPACE_EXTRA_KEY, extraRoots.value)
772-
}
773-
774-
// ===== 多根工作区(E3,phase 1):额外挂载的文件夹(Git/搜索仍走主根 rootDir)=====
775-
const WORKSPACE_EXTRA_KEY = 'workspace-extra-roots'
776-
const extraRoots = ref<string[]>(kvGetJSON<string[]>(WORKSPACE_EXTRA_KEY, []))
777-
const addWorkspaceFolder = async () => {
778-
const selected = await openDialog({directory: true, multiple: false})
779-
if (selected && typeof selected === 'string' && selected !== rootDir.value && !extraRoots.value.includes(selected)) {
780-
extraRoots.value = [...extraRoots.value, selected]
781-
kvSetJSON(WORKSPACE_EXTRA_KEY, extraRoots.value)
782-
}
783-
}
784-
const removeWorkspaceFolder = (path: string) => {
785-
extraRoots.value = extraRoots.value.filter(p => p !== path)
786-
kvSetJSON(WORKSPACE_EXTRA_KEY, extraRoots.value)
778+
resetExtraRoots()
787779
}
788780
789781
// ===== 标签会话持久化 =====
790-
const SESSION_TABS_KEY = 'session-tabs'
791-
792-
const persistSession = () => {
793-
const paths = editorTabs.value.map(t => t.filePath).filter((p): p is string => !!p)
794-
const pinned = editorTabs.value.filter(t => t.pinned && t.filePath).map(t => t.filePath as string)
795-
const activePath = editorTabs.value.find(t => t.id === activeTabId.value)?.filePath || null
796-
kvSetJSON(SESSION_TABS_KEY, {paths, pinned, activePath})
797-
}
798-
799-
// 标签集合/文件/激活项/置顶变化时持久化(不含正文编辑,避免频繁写入)
800-
watch(
801-
() => editorTabs.value.map(t => (t.pinned ? '*' : '') + (t.filePath || '')).join('|') + '#' + activeTabId.value,
802-
() => persistSession()
803-
)
804-
805-
// 启动时恢复上次打开的文件标签(仅已保存且可读的文本文件)
806-
const restoreSession = async () => {
807-
const saved = kvGetJSON<{ paths: string[], pinned?: string[], activePath: string | null } | null>(SESSION_TABS_KEY, null)
808-
if (!saved || !saved.paths?.length) {
809-
return
810-
}
811-
812-
const limitBytes = (editorConfig.value?.max_open_file_size ?? 5) * 1024 * 1024
813-
for (const p of saved.paths) {
814-
try {
815-
const meta = await invoke<{ size_bytes: number, is_text: boolean }>('get_text_file_meta', {path: p})
816-
if (meta.is_text && meta.size_bytes <= limitBytes) {
817-
await openPath(p)
818-
}
819-
}
820-
catch {
821-
// 跳过已删除/无法读取的文件
822-
}
823-
}
824-
825-
if (saved.pinned?.length) {
826-
restorePinned(saved.pinned)
827-
}
828-
829-
if (saved.activePath) {
830-
const t = editorTabs.value.find(tab => tab.filePath === saved.activePath)
831-
if (t) {
832-
switchTab(t.id)
833-
}
834-
}
835-
}
782+
// 标签会话持久化见下方 useSessionTabs(待 editorConfig 声明后初始化)
836783
837784
watch(sidebarVisible, (v) => kvSet('sidebar-visible', String(v)))
838785
@@ -1467,15 +1414,12 @@ const onLspOpenLocation = async (e: Event) => {
14671414
const showDiagnostics = ref(false)
14681415
14691416
// ===== 编辑器 LSP 右键菜单(跳转定义 / 重命名 / 格式化)=====
1470-
const editorCtx = reactive({visible: false, x: 0, y: 0, lsp: false})
1471-
const editorMenuRef = ref<HTMLElement | null>(null)
1472-
const closeEditorCtx = () => {
1473-
editorCtx.visible = false
1474-
}
1475-
14761417
// Git Blame:当前文件在已打开文件夹内时可用
14771418
const blameInfo = ref<{ root: string; rel: string; name: string } | null>(null)
14781419
const canBlame = computed(() => !!rootDir.value && !!currentFilePath.value && currentFilePath.value.startsWith(rootDir.value))
1420+
1421+
// 编辑器右键菜单(状态/定位/全局监听抽离到 useEditorContextMenu)
1422+
const {editorCtx, editorMenuRef, closeEditorCtx} = useEditorContextMenu({editorView, currentLanguage, canBlame})
14791423
const openBlame = () => {
14801424
editorCtx.visible = false
14811425
const root = rootDir.value
@@ -1499,42 +1443,6 @@ const openFileHistory = () => {
14991443
const rel = path.slice(root.length).replace(/^[\\/]/, '')
15001444
fileHistory.value = {root, rel, name: rel.split(/[\\/]/).pop() || rel}
15011445
}
1502-
const onEditorContext = async (e: MouseEvent) => {
1503-
const target = e.target as HTMLElement | null
1504-
const lsp = lspSupportsLanguage(currentLanguage.value) && !!editorView.value
1505-
// 在编辑器内容区,且支持 LSP 或可 Blame 时弹出
1506-
if (!target?.closest('.cm-content') || (!lsp && !canBlame.value)) {
1507-
return
1508-
}
1509-
editorCtx.lsp = lsp
1510-
e.preventDefault()
1511-
const view = editorView.value
1512-
if (view) {
1513-
const cur = view.state.selection.main
1514-
const pos = view.posAtCoords({x: e.clientX, y: e.clientY})
1515-
// 仅在无选区、或右键点在选区之外时才移动光标;点在选区内则保留选区(不清除高亮)
1516-
const insideSel = !cur.empty && pos != null && pos >= cur.from && pos <= cur.to
1517-
if (pos != null && !insideSel) {
1518-
view.dispatch({selection: {anchor: pos}})
1519-
}
1520-
}
1521-
// 先按光标位置弹出,渲染后测量真实尺寸再夹取到视口内(菜单项数量可变,避免贴底/贴右裁切)
1522-
editorCtx.x = e.clientX
1523-
editorCtx.y = e.clientY
1524-
editorCtx.visible = true
1525-
await nextTick()
1526-
const el = editorMenuRef.value
1527-
if (el) {
1528-
const r = el.getBoundingClientRect()
1529-
const margin = 8
1530-
if (editorCtx.x + r.width > window.innerWidth) {
1531-
editorCtx.x = Math.max(margin, window.innerWidth - r.width - margin)
1532-
}
1533-
if (editorCtx.y + r.height > window.innerHeight) {
1534-
editorCtx.y = Math.max(margin, window.innerHeight - r.height - margin)
1535-
}
1536-
}
1537-
}
15381446
const runEditorCommand = (cmd: (v: any) => boolean) => {
15391447
closeEditorCtx()
15401448
// 不在此处 focus 编辑器:重命名会弹出需要焦点的内联输入框
@@ -1648,51 +1556,8 @@ const openGit = () => {
16481556
showGit.value = true
16491557
}
16501558
1651-
// 文件树徽标用:绝对路径 → 状态字母(M/A/D/U)
1652-
const gitStatus = ref<Record<string, string>>({})
1653-
const gitRepo = ref(false)
1654-
const gitBranch = ref('')
1655-
// 计算单个根的 Git 状态(路径用绝对路径作 key,便于多根合并到同一张表)
1656-
const gitStatusFor = async (root: string): Promise<{ isRepo: boolean, branch: string, map: Record<string, string> }> => {
1657-
try {
1658-
const s = await invoke<{ is_repo: boolean, branch: string, files: { path: string, index: string, worktree: string }[] }>(
1659-
'git_status', {root}
1660-
)
1661-
const map: Record<string, string> = {}
1662-
if (s.is_repo) {
1663-
for (const f of s.files) {
1664-
const code = f.index === '?'
1665-
? 'U'
1666-
: (f.worktree.trim() || f.index.trim() || 'M')
1667-
map[`${root}/${f.path}`] = code
1668-
}
1669-
}
1670-
return {isRepo: s.is_repo, branch: s.branch || '', map}
1671-
}
1672-
catch {
1673-
return {isRepo: false, branch: '', map: {}}
1674-
}
1675-
}
1676-
const refreshGitStatus = async () => {
1677-
if (!rootDir.value) {
1678-
gitStatus.value = {}
1679-
gitRepo.value = false
1680-
gitBranch.value = ''
1681-
return
1682-
}
1683-
const primary = await gitStatusFor(rootDir.value)
1684-
gitRepo.value = primary.isRepo
1685-
gitBranch.value = primary.branch
1686-
const map: Record<string, string> = {...primary.map}
1687-
// 额外挂载的根各自可为独立仓库,合并它们的状态徽标
1688-
if (extraRoots.value.length) {
1689-
const extra = await Promise.all(extraRoots.value.map(er => gitStatusFor(er)))
1690-
for (const e of extra) Object.assign(map, e.map)
1691-
}
1692-
gitStatus.value = map
1693-
// HEAD 可能因提交/切换分支变化,刷新编辑器行内差异基线
1694-
fetchBaseline()
1695-
}
1559+
// 文件树 Git 徽标 + 当前分支(抽离到 useGitStatus);HEAD 变化时刷新差异基线
1560+
const {gitStatus, gitRepo, gitBranch, refreshGitStatus} = useGitStatus(rootDir, extraRoots, () => fetchBaseline())
16961561
16971562
// ===== 编辑器行内差异标记(vs HEAD)=====
16981563
// 当前文件在 HEAD 中的内容;null 表示无基线(新文件/非 git/未跟踪),不显示标记
@@ -1803,6 +1668,9 @@ const {
18031668
loadConfig: loadEditorConfig
18041669
} = useEditorConfig()
18051670
1671+
// 标签会话持久化(依赖 editorConfig,放在其声明之后)
1672+
const {restoreSession} = useSessionTabs({editorTabs, activeTabId, editorConfig, openPath, restorePinned, switchTab})
1673+
18061674
// 强制刷新 CodeEditor 组件的 key
18071675
const editorConfigKey = ref(0)
18081676
const consoleType = ref('console')
@@ -1912,44 +1780,8 @@ const handleSave = async () => {
19121780
}
19131781
19141782
// ===== 按文件记忆运行配置(args/stdin/env)=====
1915-
const RUN_CONFIGS_KEY = 'run-configs'
1916-
type RunConfig = { args: string, stdin: string, env: string }
1917-
const loadRunConfigs = (): Record<string, RunConfig> =>
1918-
kvGetJSON<Record<string, RunConfig>>(RUN_CONFIGS_KEY, {})
1919-
// 把当前输入写入指定文件的配置(全空则删除该条)
1920-
const saveRunConfig = (path: string) => {
1921-
const map = loadRunConfigs()
1922-
if (!runArgs.value && !runStdin.value && !runEnv.value) {
1923-
delete map[path]
1924-
}
1925-
else {
1926-
map[path] = {args: runArgs.value, stdin: runStdin.value, env: runEnv.value}
1927-
}
1928-
kvSetJSON(RUN_CONFIGS_KEY, map)
1929-
}
1930-
// 载入指定文件的配置(无则清空)
1931-
const loadRunConfig = (path: string | null) => {
1932-
const cfg = path ? loadRunConfigs()[path] : null
1933-
runArgs.value = cfg?.args || ''
1934-
runStdin.value = cfg?.stdin || ''
1935-
runEnv.value = cfg?.env || ''
1936-
}
1937-
1938-
// 切换文件时:保存旧文件输入、载入新文件输入
1939-
watch(currentFilePath, (np, op) => {
1940-
if (op) {
1941-
saveRunConfig(op)
1942-
}
1943-
loadRunConfig(np)
1944-
})
1945-
1946-
// 编辑输入时防抖保存到当前文件
1947-
const persistRunConfig = debounce(() => {
1948-
if (currentFilePath.value) {
1949-
saveRunConfig(currentFilePath.value)
1950-
}
1951-
}, 400)
1952-
watch([runArgs, runStdin, runEnv], () => persistRunConfig())
1783+
// 按文件记忆运行输入(参数/stdin/环境变量)—— 抽离到 useRunConfig
1784+
useRunConfig(currentFilePath, runArgs, runStdin, runEnv)
19531785
19541786
// 解析环境变量文本(KEY=值,按换行或分号分隔)
19551787
const parseEnv = (text: string): Record<string, string> => {
@@ -2407,7 +2239,6 @@ onMounted(async () => {
24072239
window.addEventListener('keydown', onGlobalKeydown, true)
24082240
window.addEventListener('lsp:open-location', onLspOpenLocation)
24092241
window.addEventListener('lsp:code-actions', onLspCodeActions)
2410-
window.addEventListener('contextmenu', onEditorContext)
24112242
24122243
// 触发 app-ready 事件,通知主进程
24132244
window.dispatchEvent(new CustomEvent('app-ready'))
@@ -2418,6 +2249,5 @@ onUnmounted(() => {
24182249
window.removeEventListener('keydown', onGlobalKeydown, true)
24192250
window.removeEventListener('lsp:open-location', onLspOpenLocation)
24202251
window.removeEventListener('lsp:code-actions', onLspCodeActions)
2421-
window.removeEventListener('contextmenu', onEditorContext)
24222252
})
24232253
</script>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// 编辑器右键菜单:开关状态、按光标定位并夹取到视口内、全局 contextmenu 监听。
2+
// 具体菜单项的动作仍由 App 提供(Blame/历史/AI/永久链接等),这里只管菜单本身。
3+
import {nextTick, onMounted, onUnmounted, reactive, ref, type Ref} from 'vue'
4+
import {lspSupportsLanguage} from '../editor/lspExtension'
5+
6+
interface Deps {
7+
editorView: Ref<any>
8+
currentLanguage: Ref<string>
9+
canBlame: Ref<boolean>
10+
}
11+
12+
export function useEditorContextMenu({editorView, currentLanguage, canBlame}: Deps) {
13+
const editorCtx = reactive({visible: false, x: 0, y: 0, lsp: false})
14+
const editorMenuRef = ref<HTMLElement | null>(null)
15+
16+
const closeEditorCtx = () => {
17+
editorCtx.visible = false
18+
}
19+
20+
const onEditorContext = async (e: MouseEvent) => {
21+
const target = e.target as HTMLElement | null
22+
const lsp = lspSupportsLanguage(currentLanguage.value) && !!editorView.value
23+
// 在编辑器内容区,且支持 LSP 或可 Blame 时弹出
24+
if (!target?.closest('.cm-content') || (!lsp && !canBlame.value)) {
25+
return
26+
}
27+
editorCtx.lsp = lsp
28+
e.preventDefault()
29+
const view = editorView.value
30+
if (view) {
31+
const cur = view.state.selection.main
32+
const pos = view.posAtCoords({x: e.clientX, y: e.clientY})
33+
// 仅在无选区、或右键点在选区之外时才移动光标;点在选区内则保留选区(不清除高亮)
34+
const insideSel = !cur.empty && pos != null && pos >= cur.from && pos <= cur.to
35+
if (pos != null && !insideSel) {
36+
view.dispatch({selection: {anchor: pos}})
37+
}
38+
}
39+
// 先按光标位置弹出,渲染后测量真实尺寸再夹取到视口内(菜单项数量可变,避免贴底/贴右裁切)
40+
editorCtx.x = e.clientX
41+
editorCtx.y = e.clientY
42+
editorCtx.visible = true
43+
await nextTick()
44+
const el = editorMenuRef.value
45+
if (el) {
46+
const r = el.getBoundingClientRect()
47+
const margin = 8
48+
if (editorCtx.x + r.width > window.innerWidth) {
49+
editorCtx.x = Math.max(margin, window.innerWidth - r.width - margin)
50+
}
51+
if (editorCtx.y + r.height > window.innerHeight) {
52+
editorCtx.y = Math.max(margin, window.innerHeight - r.height - margin)
53+
}
54+
}
55+
}
56+
57+
onMounted(() => window.addEventListener('contextmenu', onEditorContext))
58+
onUnmounted(() => window.removeEventListener('contextmenu', onEditorContext))
59+
60+
return {editorCtx, editorMenuRef, closeEditorCtx}
61+
}

0 commit comments

Comments
 (0)