feat: add project editor with drag & drop file management
- Backend: ProjectFileService with file CRUD, search, git status, file watcher - IPC: 12 editor channels with security validation and path containment - Store: editorSlice with multi-tab management, draft persistence, conflict detection - UI: CodeMirror 6 editor, file tree with DnD, search-in-files, context menus - Move: fs.rename with EXDEV fallback, full path remapping across all caches - Tests: comprehensive coverage for services, IPC handlers, store, and utilities
This commit is contained in:
parent
263c683b42
commit
5b0c7d13fc
74 changed files with 12317 additions and 1039 deletions
|
|
@ -82,6 +82,7 @@
|
|||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/merge": "^6.12.0",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
|
|
@ -92,6 +93,7 @@
|
|||
"@fastify/static": "^9.0.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
|
|
@ -102,6 +104,7 @@
|
|||
"@tanstack/react-virtual": "^3.10.8",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
|
|
@ -111,6 +114,7 @@
|
|||
"fastify": "^5.7.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"isbinaryfile": "^6.0.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"node-diff3": "^3.2.0",
|
||||
|
|
@ -121,6 +125,7 @@
|
|||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"simple-git": "^3.32.3",
|
||||
"ssh-config": "^5.0.4",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
|
@ -249,6 +254,7 @@
|
|||
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"node-pty"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
135
pnpm-lock.yaml
135
pnpm-lock.yaml
|
|
@ -71,6 +71,9 @@ importers:
|
|||
'@codemirror/merge':
|
||||
specifier: ^6.12.0
|
||||
version: 6.12.0
|
||||
'@codemirror/search':
|
||||
specifier: ^6.6.0
|
||||
version: 6.6.0
|
||||
'@codemirror/state':
|
||||
specifier: ^6.5.4
|
||||
version: 6.5.4
|
||||
|
|
@ -101,6 +104,9 @@ importers:
|
|||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-context-menu':
|
||||
specifier: ^2.2.16
|
||||
version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -131,6 +137,9 @@ importers:
|
|||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
chokidar:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -158,6 +167,9 @@ importers:
|
|||
idb-keyval:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
isbinaryfile:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
lucide-react:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@18.3.1)
|
||||
|
|
@ -188,6 +200,9 @@ importers:
|
|||
remark-parse:
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
simple-git:
|
||||
specifier: ^3.32.3
|
||||
version: 3.32.3
|
||||
ssh-config:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4
|
||||
|
|
@ -531,6 +546,9 @@ packages:
|
|||
'@codemirror/merge@6.12.0':
|
||||
resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==}
|
||||
|
||||
'@codemirror/search@6.6.0':
|
||||
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
|
||||
|
||||
'@codemirror/state@6.5.4':
|
||||
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
|
||||
|
||||
|
|
@ -1061,6 +1079,12 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@kwsites/file-exists@1.1.1':
|
||||
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
|
||||
|
||||
'@kwsites/promise-deferred@1.1.1':
|
||||
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
|
||||
|
||||
'@lezer/common@1.5.1':
|
||||
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
||||
|
||||
|
|
@ -1340,6 +1364,19 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context-menu@2.2.16':
|
||||
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.2':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
|
|
@ -1428,6 +1465,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-menu@2.1.16':
|
||||
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.15':
|
||||
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||
peerDependencies:
|
||||
|
|
@ -2547,6 +2597,10 @@ packages:
|
|||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
chownr@2.0.0:
|
||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3734,6 +3788,10 @@ packages:
|
|||
resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
isbinaryfile@6.0.0:
|
||||
resolution: {integrity: sha512-2FN2B8MAqKv6d5TaKsLvMrwMcghxwHTpcKy0L5mhNbRqjNqo2++SpCqN6eG1lCC1GmTQgvrYJYXv2+Chvyevag==}
|
||||
engines: {node: '>= 24.0.0'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
|
|
@ -4752,6 +4810,10 @@ packages:
|
|||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
real-require@0.2.0:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
|
@ -4989,6 +5051,9 @@ packages:
|
|||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-git@3.32.3:
|
||||
resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -6046,6 +6111,12 @@ snapshots:
|
|||
'@lezer/highlight': 1.2.3
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/search@6.6.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/state@6.5.4':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
|
@ -6552,6 +6623,14 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@kwsites/file-exists@1.1.1':
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@kwsites/promise-deferred@1.1.1': {}
|
||||
|
||||
'@lezer/common@1.5.1': {}
|
||||
|
||||
'@lezer/cpp@1.1.5':
|
||||
|
|
@ -6846,6 +6925,20 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
|
||||
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
'@radix-ui/react-context@1.1.2(@types/react@18.3.27)(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
|
@ -6926,6 +7019,32 @@ snapshots:
|
|||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
'@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
|
||||
aria-hidden: 1.2.6
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
@ -8202,6 +8321,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
chownr@2.0.0: {}
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
|
@ -9689,6 +9812,8 @@ snapshots:
|
|||
|
||||
isbinaryfile@5.0.7: {}
|
||||
|
||||
isbinaryfile@6.0.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isexe@3.1.5: {}
|
||||
|
|
@ -10931,6 +11056,8 @@ snapshots:
|
|||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
refa@0.12.1:
|
||||
|
|
@ -11238,6 +11365,14 @@ snapshots:
|
|||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-git@3.32.3:
|
||||
dependencies:
|
||||
'@kwsites/file-exists': 1.1.1
|
||||
'@kwsites/promise-deferred': 1.1.1
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { app, BrowserWindow } from 'electron';
|
|||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||
import { showTeamNativeNotification } from './ipc/teams';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
|
|
@ -562,6 +563,9 @@ function shutdownServices(): void {
|
|||
teamChangeCleanup = null;
|
||||
}
|
||||
|
||||
// Clean up editor state (watcher, git service)
|
||||
cleanupEditorState();
|
||||
|
||||
// Dispose all contexts (including local)
|
||||
if (contextRegistry) {
|
||||
contextRegistry.dispose();
|
||||
|
|
@ -731,6 +735,8 @@ function createWindow(): void {
|
|||
if (ptyTerminalService) {
|
||||
ptyTerminalService.setMainWindow(null);
|
||||
}
|
||||
setEditorMainWindow(null);
|
||||
cleanupEditorState();
|
||||
});
|
||||
|
||||
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
|
||||
|
|
@ -752,6 +758,7 @@ function createWindow(): void {
|
|||
if (ptyTerminalService) {
|
||||
ptyTerminalService.setMainWindow(mainWindow);
|
||||
}
|
||||
setEditorMainWindow(mainWindow);
|
||||
|
||||
logger.info('Main window created');
|
||||
}
|
||||
|
|
|
|||
363
src/main/ipc/editor.ts
Normal file
363
src/main/ipc/editor.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* Editor IPC handlers.
|
||||
*
|
||||
* Module-level state: `activeProjectRoot` stores the validated project path.
|
||||
* Renderer cannot override it — it's set only via `editor:open` with full validation (SEC-5).
|
||||
*/
|
||||
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||
import {
|
||||
EDITOR_CHANGE,
|
||||
EDITOR_CLOSE,
|
||||
EDITOR_CREATE_DIR,
|
||||
EDITOR_CREATE_FILE,
|
||||
EDITOR_DELETE_FILE,
|
||||
EDITOR_GIT_STATUS,
|
||||
EDITOR_MOVE_FILE,
|
||||
EDITOR_OPEN,
|
||||
EDITOR_READ_DIR,
|
||||
EDITOR_READ_FILE,
|
||||
EDITOR_SEARCH_IN_FILES,
|
||||
EDITOR_WATCH_DIR,
|
||||
EDITOR_WRITE_FILE,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
checkFileConflict,
|
||||
createSearchAbortController,
|
||||
EditorFileWatcher,
|
||||
FileSearchService,
|
||||
GitStatusService,
|
||||
ProjectFileService,
|
||||
} from '../services/editor';
|
||||
|
||||
import { createIpcWrapper } from './ipcWrapper';
|
||||
|
||||
import type {
|
||||
CreateDirResponse,
|
||||
CreateFileResponse,
|
||||
DeleteFileResponse,
|
||||
GitStatusResult,
|
||||
MoveFileResponse,
|
||||
ReadDirResult,
|
||||
ReadFileResult,
|
||||
SearchInFilesOptions,
|
||||
SearchInFilesResult,
|
||||
WriteFileResponse,
|
||||
} from '@shared/types/editor';
|
||||
import type { IpcResult } from '@shared/types/ipc';
|
||||
import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
// =============================================================================
|
||||
// Module-level state (SEC-5)
|
||||
// =============================================================================
|
||||
|
||||
let activeProjectRoot: string | null = null;
|
||||
|
||||
let mainWindowRef: BrowserWindow | null = null;
|
||||
|
||||
let activeSearchController: AbortController | null = null;
|
||||
|
||||
const projectFileService = new ProjectFileService();
|
||||
const fileSearchService = new FileSearchService();
|
||||
const gitStatusService = new GitStatusService();
|
||||
const editorFileWatcher = new EditorFileWatcher();
|
||||
const wrapHandler = createIpcWrapper('IPC:editor');
|
||||
const log = createLogger('IPC:editor');
|
||||
|
||||
// =============================================================================
|
||||
// Handlers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Initialize editor with validated project path (SEC-15).
|
||||
*/
|
||||
async function handleEditorOpen(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath: string
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('open', async () => {
|
||||
// Validate projectPath before trusting it
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
throw new Error('Invalid project path');
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(projectPath)) {
|
||||
throw new Error('Project path must be absolute');
|
||||
}
|
||||
|
||||
const normalized = path.resolve(path.normalize(projectPath));
|
||||
|
||||
// Block filesystem root
|
||||
if (normalized === '/' || /^[A-Z]:\\$/i.test(normalized)) {
|
||||
throw new Error('Cannot open filesystem root as project');
|
||||
}
|
||||
|
||||
// Block ~/.claude directory itself
|
||||
const claudeDir = getClaudeBasePath();
|
||||
if (isPathWithinRoot(normalized, claudeDir)) {
|
||||
throw new Error('Cannot open Claude data directory as project');
|
||||
}
|
||||
|
||||
// Verify it's an existing directory
|
||||
const stat = await fs.stat(normalized);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('Project path is not a directory');
|
||||
}
|
||||
|
||||
// Stop any previous watcher/git before switching projects
|
||||
editorFileWatcher.stop();
|
||||
gitStatusService.destroy();
|
||||
|
||||
activeProjectRoot = normalized;
|
||||
gitStatusService.init(normalized);
|
||||
log.info('Editor opened:', normalized);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup editor state.
|
||||
*/
|
||||
async function handleEditorClose(): Promise<IpcResult<void>> {
|
||||
return wrapHandler('close', async () => {
|
||||
editorFileWatcher.stop();
|
||||
gitStatusService.destroy();
|
||||
activeProjectRoot = null;
|
||||
log.info('Editor closed');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read directory listing (depth=1, lazy).
|
||||
*/
|
||||
async function handleEditorReadDir(
|
||||
_event: IpcMainInvokeEvent,
|
||||
dirPath: string,
|
||||
maxEntries?: number
|
||||
): Promise<IpcResult<ReadDirResult>> {
|
||||
return wrapHandler('readDir', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return projectFileService.readDir(activeProjectRoot, dirPath, maxEntries ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content with binary detection.
|
||||
*/
|
||||
async function handleEditorReadFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePath: string
|
||||
): Promise<IpcResult<ReadFileResult>> {
|
||||
return wrapHandler('readFile', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return projectFileService.readFile(activeProjectRoot, filePath);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file content with atomic write (SEC-9, SEC-12, SEC-14).
|
||||
* Optional baselineMtimeMs enables conflict detection before writing.
|
||||
*/
|
||||
async function handleEditorWriteFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePath: string,
|
||||
content: string,
|
||||
baselineMtimeMs?: number
|
||||
): Promise<IpcResult<WriteFileResponse>> {
|
||||
return wrapHandler('writeFile', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
|
||||
// Conflict detection: check if file was modified externally since last read/save
|
||||
if (baselineMtimeMs !== undefined && baselineMtimeMs > 0) {
|
||||
const conflict = await checkFileConflict(filePath, baselineMtimeMs);
|
||||
if (conflict.hasConflict) {
|
||||
if (conflict.deleted) {
|
||||
throw new Error('CONFLICT_DELETED: File was deleted externally');
|
||||
}
|
||||
throw new Error('CONFLICT: File was modified externally');
|
||||
}
|
||||
}
|
||||
|
||||
return projectFileService.writeFile(activeProjectRoot, filePath, content);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new file in the project.
|
||||
*/
|
||||
async function handleEditorCreateFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
parentDir: string,
|
||||
fileName: string
|
||||
): Promise<IpcResult<CreateFileResponse>> {
|
||||
return wrapHandler('createFile', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return projectFileService.createFile(activeProjectRoot, parentDir, fileName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new directory in the project.
|
||||
*/
|
||||
async function handleEditorCreateDir(
|
||||
_event: IpcMainInvokeEvent,
|
||||
parentDir: string,
|
||||
dirName: string
|
||||
): Promise<IpcResult<CreateDirResponse>> {
|
||||
return wrapHandler('createDir', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return projectFileService.createDir(activeProjectRoot, parentDir, dirName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file or directory (move to Trash).
|
||||
*/
|
||||
async function handleEditorDeleteFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePath: string
|
||||
): Promise<IpcResult<DeleteFileResponse>> {
|
||||
return wrapHandler('deleteFile', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return projectFileService.deleteFile(activeProjectRoot, filePath);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file or directory to a new location.
|
||||
*/
|
||||
async function handleEditorMoveFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
sourcePath: string,
|
||||
destDir: string
|
||||
): Promise<IpcResult<MoveFileResponse>> {
|
||||
return wrapHandler('moveFile', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return projectFileService.moveFile(activeProjectRoot, sourcePath, destDir);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search in files (literal string search, SEC-8 timeout).
|
||||
*/
|
||||
async function handleEditorSearchInFiles(
|
||||
_event: IpcMainInvokeEvent,
|
||||
options: SearchInFilesOptions
|
||||
): Promise<IpcResult<SearchInFilesResult>> {
|
||||
return wrapHandler('searchInFiles', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
|
||||
// Cancel any in-flight search
|
||||
if (activeSearchController) {
|
||||
activeSearchController.abort();
|
||||
}
|
||||
|
||||
const controller = createSearchAbortController();
|
||||
activeSearchController = controller;
|
||||
|
||||
try {
|
||||
return await fileSearchService.searchInFiles(activeProjectRoot, options, controller.signal);
|
||||
} finally {
|
||||
if (activeSearchController === controller) {
|
||||
activeSearchController = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git status for current project (cached 5s).
|
||||
*/
|
||||
async function handleEditorGitStatus(): Promise<IpcResult<GitStatusResult>> {
|
||||
return wrapHandler('gitStatus', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
return gitStatusService.getStatus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable file watcher for current project.
|
||||
*/
|
||||
async function handleEditorWatchDir(
|
||||
_event: IpcMainInvokeEvent,
|
||||
enable: boolean
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('watchDir', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
|
||||
if (enable) {
|
||||
editorFileWatcher.start(activeProjectRoot, (event) => {
|
||||
// Invalidate git cache on file changes
|
||||
gitStatusService.invalidateCache();
|
||||
|
||||
// Forward event to renderer
|
||||
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
|
||||
mainWindowRef.webContents.send(EDITOR_CHANGE, event);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
editorFileWatcher.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Registration
|
||||
// =============================================================================
|
||||
|
||||
export function initializeEditorHandlers(): void {
|
||||
// No external dependencies needed — service created at module level
|
||||
}
|
||||
|
||||
/**
|
||||
* Set main window reference for forwarding watcher events.
|
||||
* Called from main/index.ts after window creation.
|
||||
*/
|
||||
export function setEditorMainWindow(win: BrowserWindow | null): void {
|
||||
mainWindowRef = win;
|
||||
}
|
||||
|
||||
export function registerEditorHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(EDITOR_OPEN, handleEditorOpen);
|
||||
ipcMain.handle(EDITOR_CLOSE, handleEditorClose);
|
||||
ipcMain.handle(EDITOR_READ_DIR, handleEditorReadDir);
|
||||
ipcMain.handle(EDITOR_READ_FILE, handleEditorReadFile);
|
||||
ipcMain.handle(EDITOR_WRITE_FILE, handleEditorWriteFile);
|
||||
ipcMain.handle(EDITOR_CREATE_FILE, handleEditorCreateFile);
|
||||
ipcMain.handle(EDITOR_CREATE_DIR, handleEditorCreateDir);
|
||||
ipcMain.handle(EDITOR_DELETE_FILE, handleEditorDeleteFile);
|
||||
ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile);
|
||||
ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles);
|
||||
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
||||
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
||||
}
|
||||
|
||||
export function removeEditorHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(EDITOR_OPEN);
|
||||
ipcMain.removeHandler(EDITOR_CLOSE);
|
||||
ipcMain.removeHandler(EDITOR_READ_DIR);
|
||||
ipcMain.removeHandler(EDITOR_READ_FILE);
|
||||
ipcMain.removeHandler(EDITOR_WRITE_FILE);
|
||||
ipcMain.removeHandler(EDITOR_CREATE_FILE);
|
||||
ipcMain.removeHandler(EDITOR_CREATE_DIR);
|
||||
ipcMain.removeHandler(EDITOR_DELETE_FILE);
|
||||
ipcMain.removeHandler(EDITOR_MOVE_FILE);
|
||||
ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES);
|
||||
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
||||
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset editor state (called from mainWindow.on('closed')).
|
||||
* Prevents state leak when Cmd+Q on macOS.
|
||||
*/
|
||||
export function cleanupEditorState(): void {
|
||||
editorFileWatcher.stop();
|
||||
gitStatusService.destroy();
|
||||
activeProjectRoot = null;
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
registerContextHandlers,
|
||||
removeContextHandlers,
|
||||
} from './context';
|
||||
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
|
||||
import {
|
||||
initializeHttpServerHandlers,
|
||||
registerHttpServerHandlers,
|
||||
|
|
@ -143,6 +144,8 @@ export function initializeIpcHandlers(
|
|||
if (ptyTerminal) {
|
||||
initializeTerminalHandlers(ptyTerminal);
|
||||
}
|
||||
initializeEditorHandlers();
|
||||
|
||||
if (changeExtractor) {
|
||||
initializeReviewHandlers({
|
||||
extractor: changeExtractor,
|
||||
|
|
@ -166,6 +169,7 @@ export function initializeIpcHandlers(
|
|||
registerContextHandlers(ipcMain);
|
||||
registerTeamHandlers(ipcMain);
|
||||
registerReviewHandlers(ipcMain);
|
||||
registerEditorHandlers(ipcMain);
|
||||
registerWindowHandlers(ipcMain);
|
||||
if (cliInstaller) {
|
||||
registerCliInstallerHandlers(ipcMain);
|
||||
|
|
@ -198,6 +202,7 @@ export function removeIpcHandlers(): void {
|
|||
removeContextHandlers(ipcMain);
|
||||
removeTeamHandlers(ipcMain);
|
||||
removeReviewHandlers(ipcMain);
|
||||
removeEditorHandlers(ipcMain);
|
||||
removeWindowHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeTerminalHandlers(ipcMain);
|
||||
|
|
|
|||
25
src/main/ipc/ipcWrapper.ts
Normal file
25
src/main/ipc/ipcWrapper.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Generic IPC handler wrapper — standardizes error handling and logging.
|
||||
*
|
||||
* Creates a domain-specific wrapper that catches errors, logs them,
|
||||
* and returns IpcResult<T> for consistent renderer-side handling.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { IpcResult } from '@shared/types/ipc';
|
||||
|
||||
export function createIpcWrapper(logPrefix: string) {
|
||||
const log = createLogger(logPrefix);
|
||||
|
||||
return async function wrap<T>(operation: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
|
||||
try {
|
||||
const data = await fn();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`handler error [${operation}]:`, message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
* Паттерн: module-level state + guard + wrapReviewHandler (как teams.ts)
|
||||
*/
|
||||
|
||||
import { createIpcWrapper } from '@main/ipc/ipcWrapper';
|
||||
import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore';
|
||||
import {
|
||||
REVIEW_APPLY_DECISIONS,
|
||||
|
|
@ -22,7 +23,6 @@ import {
|
|||
REVIEW_SAVE_EDITED_FILE,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
|
||||
import type { FileContentResolver } from '@main/services/team/FileContentResolver';
|
||||
|
|
@ -43,7 +43,7 @@ import type {
|
|||
} from '@shared/types/review';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:review');
|
||||
const wrapReviewHandler = createIpcWrapper('IPC:review');
|
||||
|
||||
// --- Module-level state ---
|
||||
|
||||
|
|
@ -128,22 +128,6 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS);
|
||||
}
|
||||
|
||||
// --- Локальный wrapReviewHandler ---
|
||||
|
||||
async function wrapReviewHandler<T>(
|
||||
operation: string,
|
||||
handler: () => Promise<T>
|
||||
): Promise<IpcResult<T>> {
|
||||
try {
|
||||
const data = await handler();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Review handler error [${operation}]:`, message);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 1 Handlers ---
|
||||
|
||||
async function handleGetAgentChanges(
|
||||
|
|
|
|||
92
src/main/services/editor/EditorFileWatcher.ts
Normal file
92
src/main/services/editor/EditorFileWatcher.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* File watcher for the project editor using chokidar v4.
|
||||
*
|
||||
* Watches project directory for external file changes and emits
|
||||
* normalized events. chokidar handles platform differences (FSEvents on macOS,
|
||||
* inotify on Linux), recursive watching, and ENOSPC fallback.
|
||||
*
|
||||
* Security: paths emitted in events are validated against project root
|
||||
* before being sent to renderer (SEC-2).
|
||||
*/
|
||||
|
||||
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { watch } from 'chokidar';
|
||||
|
||||
import type { EditorFileChangeEvent } from '@shared/types/editor';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
|
||||
const log = createLogger('EditorFileWatcher');
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/** Directories to ignore (regex for chokidar's `ignored` option) */
|
||||
const IGNORED_PATTERN =
|
||||
/(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor|\.DS_Store)/;
|
||||
|
||||
const MAX_DEPTH = 20;
|
||||
|
||||
// =============================================================================
|
||||
// Service
|
||||
// =============================================================================
|
||||
|
||||
export class EditorFileWatcher {
|
||||
private watcher: FSWatcher | null = null;
|
||||
private projectRoot: string | null = null;
|
||||
|
||||
/**
|
||||
* Start watching a project directory.
|
||||
* Idempotent: stops any existing watcher first.
|
||||
*/
|
||||
start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void {
|
||||
this.stop();
|
||||
this.projectRoot = projectRoot;
|
||||
|
||||
log.info('Starting file watcher for:', projectRoot);
|
||||
|
||||
this.watcher = watch(projectRoot, {
|
||||
ignored: IGNORED_PATTERN,
|
||||
ignoreInitial: true,
|
||||
followSymlinks: false,
|
||||
depth: MAX_DEPTH,
|
||||
});
|
||||
|
||||
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
|
||||
// SEC-2: validate path is within project root before sending to renderer
|
||||
if (!isPathWithinRoot(filePath, projectRoot)) {
|
||||
log.warn('Watcher event outside project root, ignoring:', filePath);
|
||||
return;
|
||||
}
|
||||
onChange({ type, path: filePath });
|
||||
};
|
||||
|
||||
this.watcher.on('change', (p) => emitSafe('change', p));
|
||||
this.watcher.on('add', (p) => emitSafe('create', p));
|
||||
this.watcher.on('unlink', (p) => emitSafe('delete', p));
|
||||
|
||||
this.watcher.on('error', (error) => {
|
||||
log.error('Watcher error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching. Safe to call multiple times.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.watcher) {
|
||||
log.info('Stopping file watcher');
|
||||
void this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
this.projectRoot = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the watcher is currently active.
|
||||
*/
|
||||
isWatching(): boolean {
|
||||
return this.watcher !== null;
|
||||
}
|
||||
}
|
||||
232
src/main/services/editor/FileSearchService.ts
Normal file
232
src/main/services/editor/FileSearchService.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* File search service — literal string search across project files.
|
||||
*
|
||||
* Security: path containment enforced via isPathWithinRoot. .git/ blocked.
|
||||
* Performance: max 1000 files, max 1MB/file, 5s timeout via AbortController.
|
||||
*/
|
||||
|
||||
import { isGitInternalPath, isPathWithinRoot } from '@main/utils/pathValidation';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs/promises';
|
||||
import { isBinaryFile } from 'isbinaryfile';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
SearchFileResult,
|
||||
SearchInFilesOptions,
|
||||
SearchInFilesResult,
|
||||
SearchMatch,
|
||||
} from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const MAX_FILES = 1000;
|
||||
const MAX_FILE_SIZE = 1024 * 1024; // 1 MB
|
||||
const DEFAULT_MAX_RESULT_FILES = 100;
|
||||
const DEFAULT_MAX_MATCHES = 500;
|
||||
const SEARCH_TIMEOUT_MS = 5000;
|
||||
|
||||
const IGNORED_DIRS = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'.next',
|
||||
'dist',
|
||||
'__pycache__',
|
||||
'.cache',
|
||||
'.venv',
|
||||
'.tox',
|
||||
'vendor',
|
||||
'build',
|
||||
'coverage',
|
||||
'.turbo',
|
||||
]);
|
||||
|
||||
const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']);
|
||||
|
||||
const log = createLogger('FileSearchService');
|
||||
|
||||
// =============================================================================
|
||||
// Service
|
||||
// =============================================================================
|
||||
|
||||
export class FileSearchService {
|
||||
/**
|
||||
* Search for a literal string across project files.
|
||||
*
|
||||
* @param projectRoot - Validated project root path
|
||||
* @param options - Search options (query, caseSensitive, limits)
|
||||
* @param signal - Optional AbortSignal for cancellation
|
||||
*/
|
||||
async searchInFiles(
|
||||
projectRoot: string,
|
||||
options: SearchInFilesOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<SearchInFilesResult> {
|
||||
const { query, caseSensitive = false } = options;
|
||||
const maxFiles = Math.min(
|
||||
options.maxFiles ?? DEFAULT_MAX_RESULT_FILES,
|
||||
DEFAULT_MAX_RESULT_FILES
|
||||
);
|
||||
const maxMatches = Math.min(options.maxMatches ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES);
|
||||
|
||||
if (!query || query.length === 0) {
|
||||
return { results: [], totalMatches: 0, truncated: false };
|
||||
}
|
||||
|
||||
const searchQuery = caseSensitive ? query : query.toLowerCase();
|
||||
|
||||
// Collect all searchable files
|
||||
const files: string[] = [];
|
||||
await this.collectFiles(projectRoot, projectRoot, files, signal);
|
||||
|
||||
const results: SearchFileResult[] = [];
|
||||
let totalMatches = 0;
|
||||
let truncated = false;
|
||||
|
||||
for (const filePath of files) {
|
||||
if (signal?.aborted) break;
|
||||
if (results.length >= maxFiles || totalMatches >= maxMatches) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const matches = await this.searchFile(filePath, searchQuery, caseSensitive, signal);
|
||||
if (matches.length > 0) {
|
||||
const remaining = maxMatches - totalMatches;
|
||||
const trimmedMatches = matches.length > remaining ? matches.slice(0, remaining) : matches;
|
||||
|
||||
results.push({ filePath, matches: trimmedMatches });
|
||||
totalMatches += trimmedMatches.length;
|
||||
|
||||
if (totalMatches >= maxMatches) {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
return { results, totalMatches, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all searchable files.
|
||||
*/
|
||||
private async collectFiles(
|
||||
projectRoot: string,
|
||||
dirPath: string,
|
||||
files: string[],
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (signal?.aborted || files.length >= MAX_FILES) return;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // Permission denied or not a directory
|
||||
}
|
||||
|
||||
// Sort: files first for early results
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
if (a.isFile() && !b.isFile()) return -1;
|
||||
if (!a.isFile() && b.isFile()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const entry of sorted) {
|
||||
if (signal?.aborted || files.length >= MAX_FILES) break;
|
||||
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
// Security: containment check
|
||||
if (!isPathWithinRoot(fullPath, projectRoot)) continue;
|
||||
|
||||
// Block .git internal paths
|
||||
if (isGitInternalPath(fullPath)) continue;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
||||
await this.collectFiles(projectRoot, fullPath, files, signal);
|
||||
} else if (entry.isFile()) {
|
||||
if (IGNORED_FILES.has(entry.name)) continue;
|
||||
|
||||
// Skip files > 1MB
|
||||
try {
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (stat.size > MAX_FILE_SIZE) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip binary files (quick check via first 512 bytes)
|
||||
try {
|
||||
if (await isBinaryFile(fullPath)) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a single file for literal string matches.
|
||||
*/
|
||||
private async searchFile(
|
||||
filePath: string,
|
||||
query: string,
|
||||
caseSensitive: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<SearchMatch[]> {
|
||||
if (signal?.aborted) return [];
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const matches: SearchMatch[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
const line = lines[i];
|
||||
const searchLine = caseSensitive ? line : line.toLowerCase();
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const idx = searchLine.indexOf(query, startIndex);
|
||||
if (idx === -1) break;
|
||||
|
||||
matches.push({
|
||||
line: i + 1,
|
||||
column: idx,
|
||||
lineContent: line.trim(),
|
||||
});
|
||||
|
||||
startIndex = idx + query.length;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AbortController with automatic timeout.
|
||||
*/
|
||||
export function createSearchAbortController(): AbortController {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
log.warn('Search timed out after', SEARCH_TIMEOUT_MS, 'ms');
|
||||
}, SEARCH_TIMEOUT_MS);
|
||||
|
||||
// Clean up timeout when aborted by other means
|
||||
controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), { once: true });
|
||||
|
||||
return controller;
|
||||
}
|
||||
138
src/main/services/editor/GitStatusService.ts
Normal file
138
src/main/services/editor/GitStatusService.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Git status service for the project editor.
|
||||
*
|
||||
* Uses `simple-git` with --no-optional-locks (GIT_OPTIONAL_LOCKS=0) to prevent
|
||||
* .git/index.lock conflicts during background queries.
|
||||
* Results are cached for 5 seconds; invalidated on file watcher events.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { simpleGit } from 'simple-git';
|
||||
|
||||
import type { GitFileStatus, GitStatusResult } from '@shared/types/editor';
|
||||
import type { SimpleGit, StatusResult } from 'simple-git';
|
||||
|
||||
const log = createLogger('GitStatusService');
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
const CACHE_TTL_MS = 5_000;
|
||||
|
||||
// =============================================================================
|
||||
// Service
|
||||
// =============================================================================
|
||||
|
||||
export class GitStatusService {
|
||||
private git: SimpleGit | null = null;
|
||||
private projectRoot: string | null = null;
|
||||
|
||||
// Cache
|
||||
private cachedResult: GitStatusResult | null = null;
|
||||
private cacheTimestamp = 0;
|
||||
|
||||
/**
|
||||
* Initialize service for a project root.
|
||||
* Creates a simple-git instance with --no-optional-locks and timeout.
|
||||
*/
|
||||
init(projectRoot: string): void {
|
||||
this.projectRoot = projectRoot;
|
||||
this.git = simpleGit({
|
||||
baseDir: projectRoot,
|
||||
timeout: { block: GIT_TIMEOUT_MS },
|
||||
}).env('GIT_OPTIONAL_LOCKS', '0');
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset service state.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.git = null;
|
||||
this.projectRoot = null;
|
||||
this.cachedResult = null;
|
||||
this.cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached status (e.g. on file watcher event).
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.cachedResult = null;
|
||||
this.cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git status for the current project.
|
||||
* Returns cached result if within TTL.
|
||||
*/
|
||||
async getStatus(): Promise<GitStatusResult> {
|
||||
if (!this.git || !this.projectRoot) {
|
||||
return { files: [], isGitRepo: false, branch: null };
|
||||
}
|
||||
|
||||
// Return cached if fresh
|
||||
if (this.cachedResult && Date.now() - this.cacheTimestamp < CACHE_TTL_MS) {
|
||||
return this.cachedResult;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if it's a git repo first
|
||||
const isRepo = await this.isGitRepo();
|
||||
if (!isRepo) {
|
||||
const result: GitStatusResult = { files: [], isGitRepo: false, branch: null };
|
||||
this.setCacheResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const statusResult = await this.git.status();
|
||||
const files = mapStatusResult(statusResult);
|
||||
const branch = statusResult.current ?? null;
|
||||
|
||||
const result: GitStatusResult = { files, isGitRepo: true, branch };
|
||||
this.setCacheResult(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error('Failed to get git status:', error);
|
||||
// Graceful degradation: return empty non-repo result
|
||||
return { files: [], isGitRepo: false, branch: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async isGitRepo(): Promise<boolean> {
|
||||
if (!this.git) return false;
|
||||
try {
|
||||
await this.git.revparse(['--is-inside-work-tree']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private setCacheResult(result: GitStatusResult): void {
|
||||
this.cachedResult = result;
|
||||
this.cacheTimestamp = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mapping
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Map simple-git StatusResult to our GitFileStatus[] format.
|
||||
*/
|
||||
export function mapStatusResult(result: StatusResult): GitFileStatus[] {
|
||||
const files: GitFileStatus[] = [];
|
||||
for (const p of result.modified) files.push({ path: p, status: 'modified' });
|
||||
for (const p of result.not_added) files.push({ path: p, status: 'untracked' });
|
||||
for (const p of result.staged) files.push({ path: p, status: 'staged' });
|
||||
for (const p of result.deleted) files.push({ path: p, status: 'deleted' });
|
||||
for (const p of result.conflicted) files.push({ path: p, status: 'conflict' });
|
||||
for (const r of result.renamed) {
|
||||
files.push({ path: r.to, status: 'renamed', renamedFrom: r.from });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
564
src/main/services/editor/ProjectFileService.ts
Normal file
564
src/main/services/editor/ProjectFileService.ts
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
/**
|
||||
* Stateless file service for the project editor.
|
||||
*
|
||||
* Every method receives `projectRoot` as the first argument.
|
||||
* Security: path containment, symlink escape detection, device path blocking,
|
||||
* binary detection, and size limits are enforced on every call.
|
||||
*/
|
||||
|
||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
import {
|
||||
isDevicePath,
|
||||
isGitInternalPath,
|
||||
isPathWithinAllowedDirectories,
|
||||
isPathWithinRoot,
|
||||
matchesSensitivePattern,
|
||||
validateFileName,
|
||||
validateFilePath,
|
||||
} from '@main/utils/pathValidation';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { shell } from 'electron';
|
||||
import * as fs from 'fs/promises';
|
||||
import { isBinaryFile } from 'isbinaryfile';
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
CreateDirResponse,
|
||||
CreateFileResponse,
|
||||
DeleteFileResponse,
|
||||
FileTreeEntry,
|
||||
MoveFileResponse,
|
||||
ReadDirResult,
|
||||
ReadFileResult,
|
||||
WriteFileResponse,
|
||||
} from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const MAX_FILE_SIZE_FULL = 2 * 1024 * 1024; // 2 MB
|
||||
const MAX_FILE_SIZE_PREVIEW = 5 * 1024 * 1024; // 5 MB
|
||||
const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
const MAX_DIR_ENTRIES = 500;
|
||||
const PREVIEW_LINE_COUNT = 100;
|
||||
|
||||
const IGNORED_DIRS = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'.next',
|
||||
'dist',
|
||||
'__pycache__',
|
||||
'.cache',
|
||||
'.venv',
|
||||
'.tox',
|
||||
'vendor',
|
||||
]);
|
||||
|
||||
const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']);
|
||||
|
||||
const log = createLogger('ProjectFileService');
|
||||
|
||||
// =============================================================================
|
||||
// Service
|
||||
// =============================================================================
|
||||
|
||||
export class ProjectFileService {
|
||||
/**
|
||||
* Read a directory listing (depth=1, lazy loading).
|
||||
*
|
||||
* Security:
|
||||
* - Containment via isPathWithinAllowedDirectories (NOT validateFilePath — sensitive files
|
||||
* are shown with isSensitive flag, not filtered)
|
||||
* - Symlinks: realpath + re-check containment, silently skip escapes (SEC-2)
|
||||
*/
|
||||
async readDir(
|
||||
projectRoot: string,
|
||||
dirPath: string,
|
||||
maxEntries: number = MAX_DIR_ENTRIES
|
||||
): Promise<ReadDirResult> {
|
||||
const normalizedDir = path.resolve(dirPath);
|
||||
|
||||
// Containment check (allow sensitive files to be listed with flag)
|
||||
if (!isPathWithinAllowedDirectories(normalizedDir, projectRoot)) {
|
||||
throw new Error('Directory is outside project root');
|
||||
}
|
||||
|
||||
const stat = await fs.lstat(normalizedDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error('Not a directory');
|
||||
}
|
||||
|
||||
const dirents = await fs.readdir(normalizedDir, { withFileTypes: true });
|
||||
const entries: FileTreeEntry[] = [];
|
||||
let truncated = false;
|
||||
|
||||
for (const dirent of dirents) {
|
||||
// Ignore well-known noise
|
||||
if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue;
|
||||
if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue;
|
||||
|
||||
const entryPath = path.join(normalizedDir, dirent.name);
|
||||
|
||||
// Symlink handling: resolve and re-check containment
|
||||
if (dirent.isSymbolicLink()) {
|
||||
try {
|
||||
const realPath = await fs.realpath(entryPath);
|
||||
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) {
|
||||
continue; // Silently skip symlinks that escape project root (SEC-2)
|
||||
}
|
||||
const realStat = await fs.stat(realPath);
|
||||
const entry = this.buildEntry(
|
||||
dirent.name,
|
||||
entryPath,
|
||||
realStat.isDirectory() ? 'directory' : 'file',
|
||||
realStat.isFile() ? realStat.size : undefined
|
||||
);
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Broken symlink — skip silently
|
||||
continue;
|
||||
}
|
||||
} else if (dirent.isDirectory()) {
|
||||
entries.push(this.buildEntry(dirent.name, entryPath, 'directory'));
|
||||
} else if (dirent.isFile()) {
|
||||
try {
|
||||
const fileStat = await fs.stat(entryPath);
|
||||
entries.push(this.buildEntry(dirent.name, entryPath, 'file', fileStat.size));
|
||||
} catch {
|
||||
// Can't stat — include without size
|
||||
entries.push(this.buildEntry(dirent.name, entryPath, 'file'));
|
||||
}
|
||||
}
|
||||
// Skip other types (block devices, sockets, etc.)
|
||||
|
||||
if (entries.length >= maxEntries) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetical
|
||||
entries.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return { entries, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content with security checks and binary detection.
|
||||
*
|
||||
* Security:
|
||||
* - validateFilePath for traversal + sensitive check (SEC-1)
|
||||
* - Device path blocking (SEC-4)
|
||||
* - lstat + isFile check (SEC-4)
|
||||
* - Size limits (SEC-4)
|
||||
* - Post-read TOCTOU realpath verify (SEC-3)
|
||||
*/
|
||||
async readFile(projectRoot: string, filePath: string): Promise<ReadFileResult> {
|
||||
// 1. Path validation (traversal, sensitive, symlink)
|
||||
const validation = validateFilePath(filePath, projectRoot);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
const normalizedPath = validation.normalizedPath!;
|
||||
|
||||
// 2. Device path block
|
||||
if (isDevicePath(normalizedPath)) {
|
||||
throw new Error('Cannot read device files');
|
||||
}
|
||||
|
||||
// 3. File type check
|
||||
const stats = await fs.lstat(normalizedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Not a regular file');
|
||||
}
|
||||
|
||||
// 4. Size check — reject files beyond preview limit
|
||||
if (stats.size > MAX_FILE_SIZE_PREVIEW) {
|
||||
throw new Error(
|
||||
`File too large (${(stats.size / 1024 / 1024).toFixed(1)}MB). Open in external editor.`
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Binary check
|
||||
const binary = await isBinaryFile(normalizedPath);
|
||||
if (binary) {
|
||||
return {
|
||||
content: '',
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
truncated: false,
|
||||
encoding: 'binary',
|
||||
isBinary: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Read content
|
||||
const raw = await fs.readFile(normalizedPath, 'utf8');
|
||||
|
||||
// 7. Post-read TOCTOU verify
|
||||
const realPath = await fs.realpath(normalizedPath);
|
||||
const postValidation = validateFilePath(realPath, projectRoot);
|
||||
if (!postValidation.valid) {
|
||||
throw new Error('Path changed during read (TOCTOU)');
|
||||
}
|
||||
|
||||
// 8. Tiered response
|
||||
const isPreview = stats.size > MAX_FILE_SIZE_FULL;
|
||||
const content = isPreview ? raw.split('\n').slice(0, PREVIEW_LINE_COUNT).join('\n') : raw;
|
||||
|
||||
return {
|
||||
content,
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
truncated: isPreview,
|
||||
encoding: 'utf-8',
|
||||
isBinary: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file content with atomic write and full security checks.
|
||||
*
|
||||
* Security:
|
||||
* - validateFilePath for traversal + sensitive check (SEC-1)
|
||||
* - Project-only containment — block writes outside projectRoot (SEC-14)
|
||||
* - Block .git/ internal paths (SEC-12)
|
||||
* - Device path blocking (SEC-4)
|
||||
* - Content size limit (2MB)
|
||||
* - Atomic write via tmp + rename (SEC-9)
|
||||
*/
|
||||
async writeFile(
|
||||
projectRoot: string,
|
||||
filePath: string,
|
||||
content: string
|
||||
): Promise<WriteFileResponse> {
|
||||
// 1. Path validation
|
||||
const validation = validateFilePath(filePath, projectRoot);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
|
||||
const normalizedPath = validation.normalizedPath!;
|
||||
|
||||
// 2. Project-only containment (SEC-14: block ~/.claude writes)
|
||||
if (!isPathWithinRoot(normalizedPath, projectRoot)) {
|
||||
throw new Error('Path is outside project root');
|
||||
}
|
||||
|
||||
// 3. Block .git/ internal paths (SEC-12)
|
||||
if (isGitInternalPath(normalizedPath)) {
|
||||
throw new Error('Cannot write to .git/ directory');
|
||||
}
|
||||
|
||||
// 4. Device path block
|
||||
if (isDevicePath(normalizedPath)) {
|
||||
throw new Error('Cannot write to device files');
|
||||
}
|
||||
|
||||
// 5. Content size check
|
||||
const byteLength = Buffer.byteLength(content, 'utf8');
|
||||
if (byteLength > MAX_WRITE_SIZE) {
|
||||
throw new Error(
|
||||
`Content too large (${(byteLength / 1024 / 1024).toFixed(1)}MB). Maximum is 2MB.`
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Atomic write
|
||||
await atomicWriteAsync(normalizedPath, content);
|
||||
|
||||
// 7. Get post-write stats
|
||||
const stats = await fs.stat(normalizedPath);
|
||||
log.info('File saved:', normalizedPath, `(${stats.size} bytes)`);
|
||||
|
||||
return {
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new empty file.
|
||||
*
|
||||
* Security:
|
||||
* - validateFileName for traversal, control chars (SEC-1)
|
||||
* - validateFilePath for parent containment (SEC-1)
|
||||
* - isPathWithinRoot for project-only containment (SEC-14)
|
||||
* - isGitInternalPath to block .git/ writes (SEC-12)
|
||||
* - Check parent is directory, file does NOT exist
|
||||
*/
|
||||
async createFile(
|
||||
projectRoot: string,
|
||||
parentDir: string,
|
||||
fileName: string
|
||||
): Promise<CreateFileResponse> {
|
||||
// 1. Validate file name
|
||||
const nameValidation = validateFileName(fileName);
|
||||
if (!nameValidation.valid) {
|
||||
throw new Error(nameValidation.error);
|
||||
}
|
||||
|
||||
// 2. Validate parent directory path
|
||||
const parentValidation = validateFilePath(parentDir, projectRoot);
|
||||
if (!parentValidation.valid) {
|
||||
throw new Error(parentValidation.error);
|
||||
}
|
||||
const normalizedParent = parentValidation.normalizedPath!;
|
||||
|
||||
// 3. Build full path
|
||||
const fullPath = path.join(normalizedParent, fileName.trim());
|
||||
|
||||
// 4. Project-only containment (SEC-14)
|
||||
if (!isPathWithinRoot(fullPath, projectRoot)) {
|
||||
throw new Error('Path is outside project root');
|
||||
}
|
||||
|
||||
// 5. Block .git/ internal paths (SEC-12)
|
||||
if (isGitInternalPath(fullPath)) {
|
||||
throw new Error('Cannot create files in .git/ directory');
|
||||
}
|
||||
|
||||
// 6. Verify parent is a directory
|
||||
const parentStat = await fs.lstat(normalizedParent);
|
||||
if (!parentStat.isDirectory()) {
|
||||
throw new Error('Parent path is not a directory');
|
||||
}
|
||||
|
||||
// 7. Verify file does NOT exist
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
throw new Error('File already exists');
|
||||
} catch (err) {
|
||||
// Expected: ENOENT means file doesn't exist (good)
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err; // Re-throw 'File already exists' or other errors
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Create empty file
|
||||
await fs.writeFile(fullPath, '', 'utf8');
|
||||
|
||||
// 9. Get stats
|
||||
const stats = await fs.stat(fullPath);
|
||||
log.info('File created:', fullPath);
|
||||
|
||||
return { filePath: fullPath, mtimeMs: stats.mtimeMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new directory.
|
||||
*
|
||||
* Same security checks as createFile, but uses fs.mkdir.
|
||||
*/
|
||||
async createDir(
|
||||
projectRoot: string,
|
||||
parentDir: string,
|
||||
dirName: string
|
||||
): Promise<CreateDirResponse> {
|
||||
// 1. Validate directory name
|
||||
const nameValidation = validateFileName(dirName);
|
||||
if (!nameValidation.valid) {
|
||||
throw new Error(nameValidation.error);
|
||||
}
|
||||
|
||||
// 2. Validate parent directory path
|
||||
const parentValidation = validateFilePath(parentDir, projectRoot);
|
||||
if (!parentValidation.valid) {
|
||||
throw new Error(parentValidation.error);
|
||||
}
|
||||
const normalizedParent = parentValidation.normalizedPath!;
|
||||
|
||||
// 3. Build full path
|
||||
const fullPath = path.join(normalizedParent, dirName.trim());
|
||||
|
||||
// 4. Project-only containment (SEC-14)
|
||||
if (!isPathWithinRoot(fullPath, projectRoot)) {
|
||||
throw new Error('Path is outside project root');
|
||||
}
|
||||
|
||||
// 5. Block .git/ internal paths (SEC-12)
|
||||
if (isGitInternalPath(fullPath)) {
|
||||
throw new Error('Cannot create directories in .git/ directory');
|
||||
}
|
||||
|
||||
// 6. Verify parent is a directory
|
||||
const parentStat = await fs.lstat(normalizedParent);
|
||||
if (!parentStat.isDirectory()) {
|
||||
throw new Error('Parent path is not a directory');
|
||||
}
|
||||
|
||||
// 7. Verify directory does NOT exist
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
throw new Error('Directory already exists');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Create directory
|
||||
await fs.mkdir(fullPath);
|
||||
log.info('Directory created:', fullPath);
|
||||
|
||||
return { dirPath: fullPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file or directory by moving it to the system Trash.
|
||||
*
|
||||
* Security:
|
||||
* - validateFilePath for containment (SEC-1)
|
||||
* - isPathWithinRoot for project-only containment (SEC-14)
|
||||
* - isGitInternalPath to block .git/ deletes (SEC-12)
|
||||
* - Uses shell.trashItem for safe, reversible deletion
|
||||
*/
|
||||
async deleteFile(projectRoot: string, filePath: string): Promise<DeleteFileResponse> {
|
||||
// 1. Validate file path
|
||||
const validation = validateFilePath(filePath, projectRoot);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
const normalizedPath = validation.normalizedPath!;
|
||||
|
||||
// 2. Project-only containment (SEC-14)
|
||||
if (!isPathWithinRoot(normalizedPath, projectRoot)) {
|
||||
throw new Error('Path is outside project root');
|
||||
}
|
||||
|
||||
// 3. Block .git/ internal paths (SEC-12)
|
||||
if (isGitInternalPath(normalizedPath)) {
|
||||
throw new Error('Cannot delete files in .git/ directory');
|
||||
}
|
||||
|
||||
// 4. Verify path exists
|
||||
await fs.lstat(normalizedPath);
|
||||
|
||||
// 5. Move to Trash (safe, reversible)
|
||||
await shell.trashItem(normalizedPath);
|
||||
log.info('File moved to Trash:', normalizedPath);
|
||||
|
||||
return { deletedPath: normalizedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file or directory to a new location within the project.
|
||||
*
|
||||
* Security:
|
||||
* - validateFilePath for traversal + sensitive check (SEC-1)
|
||||
* - isPathWithinRoot for project-only containment (SEC-14)
|
||||
* - isGitInternalPath to block .git/ moves (SEC-12)
|
||||
* - Parent → child move prevention
|
||||
* - Name collision detection
|
||||
* - EXDEV cross-device fallback (fs.cp + fs.rm)
|
||||
*/
|
||||
async moveFile(
|
||||
projectRoot: string,
|
||||
sourcePath: string,
|
||||
destDir: string
|
||||
): Promise<MoveFileResponse> {
|
||||
// 1. Validate source path
|
||||
const srcValidation = validateFilePath(sourcePath, projectRoot);
|
||||
if (!srcValidation.valid) {
|
||||
throw new Error(srcValidation.error);
|
||||
}
|
||||
const normalizedSrc = srcValidation.normalizedPath!;
|
||||
|
||||
// 2. Validate dest directory path
|
||||
const destValidation = validateFilePath(destDir, projectRoot);
|
||||
if (!destValidation.valid) {
|
||||
throw new Error(destValidation.error);
|
||||
}
|
||||
const normalizedDest = destValidation.normalizedPath!;
|
||||
|
||||
// 3. Project containment (SEC-14)
|
||||
if (!isPathWithinRoot(normalizedSrc, projectRoot)) {
|
||||
throw new Error('Source path is outside project root');
|
||||
}
|
||||
if (!isPathWithinRoot(normalizedDest, projectRoot)) {
|
||||
throw new Error('Destination path is outside project root');
|
||||
}
|
||||
|
||||
// 4. Block .git/ paths (SEC-12)
|
||||
if (isGitInternalPath(normalizedSrc)) {
|
||||
throw new Error('Cannot move files from .git/ directory');
|
||||
}
|
||||
if (isGitInternalPath(normalizedDest)) {
|
||||
throw new Error('Cannot move files into .git/ directory');
|
||||
}
|
||||
|
||||
// 5. Verify source exists
|
||||
await fs.lstat(normalizedSrc);
|
||||
|
||||
// 6. Verify destination is a directory
|
||||
const destStat = await fs.lstat(normalizedDest);
|
||||
if (!destStat.isDirectory()) {
|
||||
throw new Error('Destination is not a directory');
|
||||
}
|
||||
|
||||
// 7. Build new path
|
||||
const newPath = path.join(normalizedDest, path.basename(normalizedSrc));
|
||||
|
||||
// 8. Prevent parent → child move (moving dir into itself)
|
||||
if (normalizedDest.startsWith(normalizedSrc + '/') || normalizedDest === normalizedSrc) {
|
||||
throw new Error('Cannot move a directory into itself');
|
||||
}
|
||||
|
||||
// 9. Check destination doesn't already exist
|
||||
try {
|
||||
await fs.access(newPath);
|
||||
throw new Error('File already exists at destination');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Block sensitive destination
|
||||
if (matchesSensitivePattern(newPath)) {
|
||||
throw new Error('Cannot move to sensitive file location');
|
||||
}
|
||||
|
||||
// 11. Perform rename with EXDEV fallback
|
||||
try {
|
||||
await fs.rename(normalizedSrc, newPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
const stat = await fs.lstat(normalizedSrc);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.cp(normalizedSrc, newPath, { recursive: true });
|
||||
} else {
|
||||
await fs.copyFile(normalizedSrc, newPath);
|
||||
}
|
||||
await fs.rm(normalizedSrc, { recursive: true, force: true });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
log.info('File moved:', normalizedSrc, '→', newPath);
|
||||
return { newPath };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private buildEntry(
|
||||
name: string,
|
||||
entryPath: string,
|
||||
type: 'file' | 'directory',
|
||||
size?: number
|
||||
): FileTreeEntry {
|
||||
const entry: FileTreeEntry = { name, path: entryPath, type };
|
||||
if (size !== undefined) entry.size = size;
|
||||
if (matchesSensitivePattern(entryPath)) entry.isSensitive = true;
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
export { MAX_DIR_ENTRIES, MAX_FILE_SIZE_FULL, MAX_FILE_SIZE_PREVIEW, MAX_WRITE_SIZE };
|
||||
52
src/main/services/editor/conflictDetection.ts
Normal file
52
src/main/services/editor/conflictDetection.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Conflict detection utility for the project editor.
|
||||
*
|
||||
* Checks if a file has been modified externally since the last known mtime.
|
||||
* Used before saving to prevent silently overwriting external changes.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface ConflictCheckResult {
|
||||
/** True if the file was modified externally */
|
||||
hasConflict: boolean;
|
||||
/** Current mtime on disk */
|
||||
currentMtimeMs: number;
|
||||
/** True if the file no longer exists on disk */
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if a file has been modified since the given baseline mtime.
|
||||
*
|
||||
* @param filePath - Absolute path to the file
|
||||
* @param baselineMtimeMs - Last known mtime (from readFile result)
|
||||
* @returns Conflict check result
|
||||
*/
|
||||
export async function checkFileConflict(
|
||||
filePath: string,
|
||||
baselineMtimeMs: number
|
||||
): Promise<ConflictCheckResult> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
const currentMtimeMs = stats.mtimeMs;
|
||||
|
||||
// Allow 1ms tolerance for filesystem rounding
|
||||
const hasConflict = Math.abs(currentMtimeMs - baselineMtimeMs) > 1;
|
||||
|
||||
return { hasConflict, currentMtimeMs, deleted: false };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { hasConflict: true, currentMtimeMs: 0, deleted: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
5
src/main/services/editor/index.ts
Normal file
5
src/main/services/editor/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { checkFileConflict } from './conflictDetection';
|
||||
export { EditorFileWatcher } from './EditorFileWatcher';
|
||||
export { createSearchAbortController, FileSearchService } from './FileSearchService';
|
||||
export { GitStatusService, mapStatusResult } from './GitStatusService';
|
||||
export { ProjectFileService } from './ProjectFileService';
|
||||
|
|
@ -1,39 +1,5 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Async atomic write: write tmp file then rename over target.
|
||||
* Uses best-effort fsync and EXDEV fallback for safety.
|
||||
* Re-export from canonical location.
|
||||
* Kept to avoid breaking existing imports — new code should import from @main/utils/atomicWrite.
|
||||
*/
|
||||
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
|
||||
const dir = path.dirname(targetPath);
|
||||
const tmpPath = path.join(dir, `.tmp.${randomUUID()}`);
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(tmpPath, data, 'utf8');
|
||||
|
||||
try {
|
||||
const fd = await fs.promises.open(tmpPath, 'r+');
|
||||
await fd.sync();
|
||||
await fd.close();
|
||||
} catch {
|
||||
// fsync is best-effort.
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rename(tmpPath, targetPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
await fs.promises.copyFile(tmpPath, targetPath);
|
||||
await fs.promises.unlink(tmpPath).catch(() => undefined);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.promises.unlink(tmpPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
|
|
|
|||
39
src/main/utils/atomicWrite.ts
Normal file
39
src/main/utils/atomicWrite.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Async atomic write: write tmp file then rename over target.
|
||||
* Uses best-effort fsync and EXDEV fallback for safety.
|
||||
*/
|
||||
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
|
||||
const dir = path.dirname(targetPath);
|
||||
const tmpPath = path.join(dir, `.tmp.${randomUUID()}`);
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(tmpPath, data, 'utf8');
|
||||
|
||||
try {
|
||||
const fd = await fs.promises.open(tmpPath, 'r+');
|
||||
await fd.sync();
|
||||
await fd.close();
|
||||
} catch {
|
||||
// fsync is best-effort.
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rename(tmpPath, targetPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
await fs.promises.copyFile(tmpPath, targetPath);
|
||||
await fs.promises.unlink(tmpPath).catch(() => undefined);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.promises.unlink(tmpPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ function normalizeForCompare(input: string, isWindows: boolean): string {
|
|||
return isWindows ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep);
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ function resolveRealPathIfExists(inputPath: string): string | null {
|
|||
* @param normalizedPath - The normalized absolute path to check
|
||||
* @returns true if path matches a sensitive pattern
|
||||
*/
|
||||
function matchesSensitivePattern(normalizedPath: string): boolean {
|
||||
export function matchesSensitivePattern(normalizedPath: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(normalizedPath));
|
||||
}
|
||||
|
||||
|
|
@ -303,3 +303,67 @@ export function validateOpenPath(
|
|||
|
||||
return { valid: true, normalizedPath };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Editor-specific validation utilities
|
||||
// =============================================================================
|
||||
|
||||
const MAX_FILENAME_LENGTH = 255;
|
||||
|
||||
/** Characters forbidden in file/directory names. */
|
||||
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
|
||||
const INVALID_FILENAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
|
||||
|
||||
/**
|
||||
* Validates a file or directory name for creation.
|
||||
* Prevents path traversal, control chars, and OS-invalid characters.
|
||||
*/
|
||||
export function validateFileName(name: string): PathValidationResult {
|
||||
if (!name || typeof name !== 'string') {
|
||||
return { valid: false, error: 'Name is required' };
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { valid: false, error: 'Name cannot be empty' };
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_FILENAME_LENGTH) {
|
||||
return { valid: false, error: `Name exceeds ${MAX_FILENAME_LENGTH} characters` };
|
||||
}
|
||||
|
||||
if (trimmed === '.' || trimmed === '..') {
|
||||
return { valid: false, error: 'Invalid name' };
|
||||
}
|
||||
|
||||
if (INVALID_FILENAME_CHARS.test(trimmed)) {
|
||||
return { valid: false, error: 'Name contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/** Blocked device/pseudo-filesystem path prefixes. */
|
||||
const DEVICE_PATH_PREFIXES = ['/dev/', '/proc/', '/sys/'];
|
||||
const WINDOWS_DEVICE_PREFIX = '\\\\.\\';
|
||||
|
||||
/**
|
||||
* Returns true if the path points to a device or pseudo-filesystem
|
||||
* (/dev/, /proc/, /sys/, \\\\.\\).
|
||||
*/
|
||||
export function isDevicePath(filePath: string): boolean {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (DEVICE_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
return filePath.startsWith(WINDOWS_DEVICE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the path contains a `.git/` segment.
|
||||
* Used to block writes to git internals.
|
||||
*/
|
||||
export function isGitInternalPath(filePath: string): boolean {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
return normalized.includes('/.git/') || normalized.endsWith('/.git');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -396,3 +396,46 @@ export const REVIEW_SAVE_DECISIONS = 'review:saveDecisions';
|
|||
|
||||
/** Clear review decisions from disk */
|
||||
export const REVIEW_CLEAR_DECISIONS = 'review:clearDecisions';
|
||||
|
||||
// =============================================================================
|
||||
// Editor Channels
|
||||
// =============================================================================
|
||||
|
||||
/** Initialize editor, set activeProjectRoot in module-level state */
|
||||
export const EDITOR_OPEN = 'editor:open';
|
||||
|
||||
/** Cleanup: reset activeProjectRoot, stop watcher */
|
||||
export const EDITOR_CLOSE = 'editor:close';
|
||||
|
||||
/** Recursive directory reading (depth=1, lazy) */
|
||||
export const EDITOR_READ_DIR = 'editor:readDir';
|
||||
|
||||
/** Read file content with binary detection */
|
||||
export const EDITOR_READ_FILE = 'editor:readFile';
|
||||
|
||||
/** Write file content (atomic write) */
|
||||
export const EDITOR_WRITE_FILE = 'editor:writeFile';
|
||||
|
||||
/** Create a new file */
|
||||
export const EDITOR_CREATE_FILE = 'editor:createFile';
|
||||
|
||||
/** Create a new directory */
|
||||
export const EDITOR_CREATE_DIR = 'editor:createDir';
|
||||
|
||||
/** Delete file or directory (move to Trash) */
|
||||
export const EDITOR_DELETE_FILE = 'editor:deleteFile';
|
||||
|
||||
/** Move file or directory to a new location */
|
||||
export const EDITOR_MOVE_FILE = 'editor:moveFile';
|
||||
|
||||
/** Search in files (literal string search) */
|
||||
export const EDITOR_SEARCH_IN_FILES = 'editor:searchInFiles';
|
||||
|
||||
/** Get git status for current project */
|
||||
export const EDITOR_GIT_STATUS = 'editor:gitStatus';
|
||||
|
||||
/** Enable/disable file watcher for current project */
|
||||
export const EDITOR_WATCH_DIR = 'editor:watchDir';
|
||||
|
||||
/** File change event from watcher (main -> renderer) */
|
||||
export const EDITOR_CHANGE = 'editor:change';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@ import {
|
|||
CONTEXT_GET_ACTIVE,
|
||||
CONTEXT_LIST,
|
||||
CONTEXT_SWITCH,
|
||||
EDITOR_CHANGE,
|
||||
EDITOR_CLOSE,
|
||||
EDITOR_CREATE_DIR,
|
||||
EDITOR_CREATE_FILE,
|
||||
EDITOR_DELETE_FILE,
|
||||
EDITOR_GIT_STATUS,
|
||||
EDITOR_MOVE_FILE,
|
||||
EDITOR_OPEN,
|
||||
EDITOR_READ_DIR,
|
||||
EDITOR_READ_FILE,
|
||||
EDITOR_SEARCH_IN_FILES,
|
||||
EDITOR_WATCH_DIR,
|
||||
EDITOR_WRITE_FILE,
|
||||
HTTP_SERVER_GET_STATUS,
|
||||
HTTP_SERVER_START,
|
||||
HTTP_SERVER_STOP,
|
||||
|
|
@ -179,6 +192,19 @@ import type {
|
|||
UpdateKanbanPatch,
|
||||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type {
|
||||
CreateDirResponse,
|
||||
CreateFileResponse,
|
||||
DeleteFileResponse,
|
||||
EditorFileChangeEvent,
|
||||
GitStatusResult,
|
||||
MoveFileResponse,
|
||||
ReadDirResult,
|
||||
ReadFileResult,
|
||||
SearchInFilesOptions,
|
||||
SearchInFilesResult,
|
||||
WriteFileResponse,
|
||||
} from '@shared/types/editor';
|
||||
import type { PtySpawnOptions } from '@shared/types/terminal';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -920,6 +946,37 @@ const electronAPI: ElectronAPI = {
|
|||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ===== Editor API =====
|
||||
editor: {
|
||||
open: (projectPath: string) => invokeIpcWithResult<void>(EDITOR_OPEN, projectPath),
|
||||
close: () => invokeIpcWithResult<void>(EDITOR_CLOSE),
|
||||
readDir: (dirPath: string, maxEntries?: number) =>
|
||||
invokeIpcWithResult<ReadDirResult>(EDITOR_READ_DIR, dirPath, maxEntries),
|
||||
readFile: (filePath: string) => invokeIpcWithResult<ReadFileResult>(EDITOR_READ_FILE, filePath),
|
||||
writeFile: (filePath: string, content: string, baselineMtimeMs?: number) =>
|
||||
invokeIpcWithResult<WriteFileResponse>(EDITOR_WRITE_FILE, filePath, content, baselineMtimeMs),
|
||||
createFile: (parentDir: string, fileName: string) =>
|
||||
invokeIpcWithResult<CreateFileResponse>(EDITOR_CREATE_FILE, parentDir, fileName),
|
||||
createDir: (parentDir: string, dirName: string) =>
|
||||
invokeIpcWithResult<CreateDirResponse>(EDITOR_CREATE_DIR, parentDir, dirName),
|
||||
deleteFile: (filePath: string) =>
|
||||
invokeIpcWithResult<DeleteFileResponse>(EDITOR_DELETE_FILE, filePath),
|
||||
moveFile: (sourcePath: string, destDir: string) =>
|
||||
invokeIpcWithResult<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
|
||||
searchInFiles: (options: SearchInFilesOptions) =>
|
||||
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
|
||||
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
|
||||
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
|
||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
|
||||
callback(data);
|
||||
ipcRenderer.on(EDITOR_CHANGE, listener);
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener(EDITOR_CHANGE, listener);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Use contextBridge to securely expose the API to the renderer process
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import type {
|
|||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type { AgentConfig } from '@shared/types/api';
|
||||
import type { EditorAPI } from '@shared/types/editor';
|
||||
import type { TerminalAPI } from '@shared/types/terminal';
|
||||
|
||||
export class HttpAPIClient implements ElectronAPI {
|
||||
|
|
@ -903,4 +904,50 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
onData: (): (() => void) => () => {},
|
||||
onExit: (): (() => void) => () => {},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor (not available in browser mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
editor: EditorAPI = {
|
||||
open: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
close: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
readDir: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
readFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
createFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
createDir: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
deleteFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
moveFile: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
searchInFiles: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
gitStatus: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
watchDir: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
onEditorChange: () => {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
182
src/renderer/components/common/FileTree.tsx
Normal file
182
src/renderer/components/common/FileTree.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Generic file tree component with render-props for customization.
|
||||
*
|
||||
* Used by EditorFileTree (FileTreeEntry) and ReviewFileTree (FileChangeSummary).
|
||||
* ARIA: role="tree", role="treeitem", aria-expanded, role="group".
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { TreeNode } from '@renderer/utils/fileTreeBuilder';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface FileTreeProps<T> {
|
||||
nodes: TreeNode<T>[];
|
||||
activeNodePath: string | null;
|
||||
onNodeClick: (node: TreeNode<T>) => void;
|
||||
expandedPaths: Record<string, boolean>;
|
||||
onToggleExpand: (fullPath: string) => void;
|
||||
renderLeafNode?: (node: TreeNode<T>, isSelected: boolean, depth: number) => React.ReactNode;
|
||||
renderFolderLabel?: (node: TreeNode<T>, isOpen: boolean, depth: number) => React.ReactNode;
|
||||
renderNodeIcon?: (node: TreeNode<T>) => React.ReactNode;
|
||||
/** Optional data attributes placed on each <li> for event delegation (e.g. context menu) */
|
||||
getNodeDataAttrs?: (node: TreeNode<T>) => Record<string, string>;
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const MAX_VISUAL_DEPTH = 12;
|
||||
const INDENT_PX = 12;
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const FileTree = <T,>(props: Readonly<FileTreeProps<T>>): React.ReactElement => {
|
||||
const { nodes, maxDepth = MAX_VISUAL_DEPTH } = props;
|
||||
|
||||
return (
|
||||
<ul role="tree" className="select-none text-sm">
|
||||
{nodes.map((node) => (
|
||||
<TreeItem key={node.fullPath} node={node} depth={0} maxDepth={maxDepth} {...props} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// TreeItem (recursive)
|
||||
// =============================================================================
|
||||
|
||||
interface TreeItemProps<T> extends FileTreeProps<T> {
|
||||
node: TreeNode<T>;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const TreeItemInner = <T,>({
|
||||
node,
|
||||
depth,
|
||||
activeNodePath,
|
||||
onNodeClick,
|
||||
expandedPaths,
|
||||
onToggleExpand,
|
||||
renderLeafNode,
|
||||
renderFolderLabel,
|
||||
renderNodeIcon,
|
||||
getNodeDataAttrs,
|
||||
maxDepth = MAX_VISUAL_DEPTH,
|
||||
nodes: _nodes,
|
||||
...rest
|
||||
}: Readonly<TreeItemProps<T>>): React.ReactElement => {
|
||||
const visualDepth = Math.min(depth, maxDepth);
|
||||
const isSelected = activeNodePath === node.fullPath;
|
||||
const dataAttrs = getNodeDataAttrs?.(node);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (node.isFile) {
|
||||
onNodeClick(node);
|
||||
} else {
|
||||
onToggleExpand(node.fullPath);
|
||||
}
|
||||
}, [node, onNodeClick, onToggleExpand]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
},
|
||||
[handleClick]
|
||||
);
|
||||
|
||||
// Leaf node (file)
|
||||
if (node.isFile) {
|
||||
if (renderLeafNode) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading
|
||||
<li role="treeitem" aria-selected={isSelected} {...dataAttrs}>
|
||||
{renderLeafNode(node, isSelected, visualDepth)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
role="treeitem"
|
||||
aria-selected={isSelected}
|
||||
className={`flex cursor-pointer items-center gap-1 truncate px-2 py-0.5 hover:bg-surface-raised ${
|
||||
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
|
||||
}`}
|
||||
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
title={node.fullPath}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading
|
||||
{...dataAttrs}
|
||||
>
|
||||
{renderNodeIcon?.(node)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Folder node
|
||||
const isExpanded = expandedPaths[node.fullPath] === true;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading
|
||||
<li role="treeitem" aria-expanded={isExpanded} aria-selected={isSelected} {...dataAttrs}>
|
||||
{renderFolderLabel ? (
|
||||
renderFolderLabel(node, isExpanded, visualDepth)
|
||||
) : (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 truncate px-2 py-0.5 text-text-secondary hover:bg-surface-raised"
|
||||
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={depth >= maxDepth ? node.fullPath : undefined}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-text-muted" />
|
||||
)}
|
||||
{renderNodeIcon?.(node)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && node.children.length > 0 && (
|
||||
<ul role="group">
|
||||
{node.children.map((child) => (
|
||||
<TreeItemInner
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activeNodePath={activeNodePath}
|
||||
onNodeClick={onNodeClick}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={onToggleExpand}
|
||||
renderLeafNode={renderLeafNode}
|
||||
renderFolderLabel={renderFolderLabel}
|
||||
renderNodeIcon={renderNodeIcon}
|
||||
getNodeDataAttrs={getNodeDataAttrs}
|
||||
maxDepth={maxDepth}
|
||||
nodes={[]}
|
||||
{...rest}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const TreeItem = React.memo(TreeItemInner) as typeof TreeItemInner;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -39,6 +39,8 @@ interface SendMessageDialogProps {
|
|||
open: boolean;
|
||||
members: ResolvedTeamMember[];
|
||||
defaultRecipient?: string;
|
||||
/** Pre-filled message text (e.g. from editor selection action) */
|
||||
defaultText?: string;
|
||||
quotedMessage?: QuotedMessage;
|
||||
sending: boolean;
|
||||
sendError: string | null;
|
||||
|
|
@ -53,6 +55,7 @@ export const SendMessageDialog = ({
|
|||
open,
|
||||
members,
|
||||
defaultRecipient,
|
||||
defaultText,
|
||||
quotedMessage,
|
||||
sending,
|
||||
sendError,
|
||||
|
|
@ -74,6 +77,9 @@ export const SendMessageDialog = ({
|
|||
setSummary('');
|
||||
setQuote(quotedMessage);
|
||||
setPrevResult(lastResult);
|
||||
if (defaultText) {
|
||||
textDraft.setValue(defaultText);
|
||||
}
|
||||
}
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
|
|
|
|||
481
src/renderer/components/team/editor/CodeMirrorEditor.tsx
Normal file
481
src/renderer/components/team/editor/CodeMirrorEditor.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
/**
|
||||
* Editable CodeMirror 6 editor with EditorState pooling.
|
||||
*
|
||||
* Single EditorView, Map<filePath, EditorState> in useRef.
|
||||
* Cmd+S keymap, debounced dirty flag, draft autosave to localStorage.
|
||||
* LRU eviction at >30 cached states.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands';
|
||||
import { bracketMatching, indentOnInput, syntaxHighlighting } from '@codemirror/language';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import {
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getAsyncLanguageDesc,
|
||||
getSyncLanguageExtension,
|
||||
} from '@renderer/utils/codemirrorLanguages';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const MAX_CACHED_STATES = 30;
|
||||
const DIRTY_DEBOUNCE_MS = 300;
|
||||
const AUTOSAVE_DELAY_MS = 30_000;
|
||||
const MAX_DRAFT_SIZE = 500 * 1024; // 500KB
|
||||
const MAX_DRAFTS = 10;
|
||||
const SELECTION_DEBOUNCE_MS = 150;
|
||||
const MAX_SELECTION_TEXT = 5000;
|
||||
|
||||
/** Compartment for dynamic line wrap toggling */
|
||||
const lineWrapCompartment = new Compartment();
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface CodeMirrorEditorProps {
|
||||
/** Currently active file path (tab id) */
|
||||
filePath: string;
|
||||
/** Initial content to load if no cached state exists */
|
||||
content: string;
|
||||
/** File name for language detection */
|
||||
fileName: string;
|
||||
/** File modification time (for draft comparison) */
|
||||
mtimeMs?: number;
|
||||
/** Cursor position callback for status bar */
|
||||
onCursorChange?: (line: number, col: number) => void;
|
||||
/** Called when a draft was recovered from localStorage */
|
||||
onDraftRecovered?: (filePath: string) => void;
|
||||
/** Called when text selection changes (for floating action menu) */
|
||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Selection info helper
|
||||
// =============================================================================
|
||||
|
||||
function buildSelectionInfo(
|
||||
view: EditorView,
|
||||
sel: { from: number; to: number }
|
||||
): EditorSelectionInfo | null {
|
||||
const coords = view.coordsAtPos(sel.to);
|
||||
if (!coords) return null; // selection end is off-screen
|
||||
|
||||
let text = view.state.sliceDoc(sel.from, sel.to);
|
||||
if (text.length > MAX_SELECTION_TEXT) {
|
||||
text = text.slice(0, MAX_SELECTION_TEXT) + '…';
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
filePath: '', // filled by parent (CodeMirrorEditor has no file context in buildEditableExtensions)
|
||||
fromLine: view.state.doc.lineAt(sel.from).number,
|
||||
toLine: view.state.doc.lineAt(sel.to).number,
|
||||
screenRect: {
|
||||
top: coords.top,
|
||||
right: coords.right ?? coords.left,
|
||||
bottom: coords.bottom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Extensions builder
|
||||
// =============================================================================
|
||||
|
||||
function buildEditableExtensions(
|
||||
fileName: string,
|
||||
onSave: () => void,
|
||||
onUpdate: () => void,
|
||||
onCursorMove: (line: number, col: number) => void,
|
||||
onSelectionEmit: (info: EditorSelectionInfo | null) => void,
|
||||
onScrollReposition: (info: EditorSelectionInfo | null) => void
|
||||
): Extension[] {
|
||||
const syncLang = getSyncLanguageExtension(fileName);
|
||||
const asyncLang = getAsyncLanguageDesc(fileName);
|
||||
|
||||
const extensions: Extension[] = [
|
||||
// Theme
|
||||
baseEditorTheme,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
|
||||
// UI
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
highlightActiveLineGutter(),
|
||||
bracketMatching(),
|
||||
indentOnInput(),
|
||||
|
||||
// History
|
||||
history(),
|
||||
|
||||
// Search (Cmd+F)
|
||||
search(),
|
||||
|
||||
// Save keymap (Cmd+S / Ctrl+S)
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: () => {
|
||||
onSave();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
// Undo/Redo already in historyKeymap, but explicitly add for toolbar
|
||||
{
|
||||
key: 'Mod-z',
|
||||
run: (view) => undo(view),
|
||||
},
|
||||
{
|
||||
key: 'Mod-Shift-z',
|
||||
run: (view) => redo(view),
|
||||
},
|
||||
]),
|
||||
|
||||
// Keymaps
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
|
||||
// Update listener for dirty flag + cursor position + selection
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onUpdate();
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const pos = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(pos);
|
||||
onCursorMove(line.number, pos - line.from + 1);
|
||||
|
||||
// Selection change detection
|
||||
const sel = update.state.selection.main;
|
||||
if (sel.empty) {
|
||||
onSelectionEmit(null);
|
||||
} else {
|
||||
onSelectionEmit(buildSelectionInfo(update.view, sel));
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Re-emit selection coords on scroll — immediate (no debounce) to avoid drift
|
||||
EditorView.domEventHandlers({
|
||||
scroll: (_event, view) => {
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return;
|
||||
onScrollReposition(buildSelectionInfo(view, sel));
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
if (syncLang) {
|
||||
extensions.push(syncLang);
|
||||
} else if (asyncLang) {
|
||||
extensions.push(asyncLang.support ?? []);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Draft autosave helpers
|
||||
// =============================================================================
|
||||
|
||||
function saveDraft(filePath: string, content: string): void {
|
||||
try {
|
||||
if (content.length > MAX_DRAFT_SIZE) return;
|
||||
|
||||
const key = `editor-draft:${filePath}`;
|
||||
const value = JSON.stringify({ content, timestamp: Date.now() });
|
||||
localStorage.setItem(key, value);
|
||||
|
||||
// Enforce max drafts limit
|
||||
enforceDraftLimit();
|
||||
} catch {
|
||||
// localStorage may be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function enforceDraftLimit(): void {
|
||||
try {
|
||||
const drafts: { key: string; timestamp: number }[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key?.startsWith('editor-draft:')) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(key)!) as { timestamp: number };
|
||||
drafts.push({ key, timestamp: parsed.timestamp });
|
||||
} catch {
|
||||
// corrupted draft — remove
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (drafts.length > MAX_DRAFTS) {
|
||||
drafts.sort((a, b) => a.timestamp - b.timestamp);
|
||||
const toRemove = drafts.slice(0, drafts.length - MAX_DRAFTS);
|
||||
for (const d of toRemove) {
|
||||
localStorage.removeItem(d.key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const CodeMirrorEditor = ({
|
||||
filePath,
|
||||
content,
|
||||
fileName,
|
||||
mtimeMs,
|
||||
onCursorChange,
|
||||
onDraftRecovered,
|
||||
onSelectionChange,
|
||||
}: CodeMirrorEditorProps): React.ReactElement => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const stateCacheRef = useRef(new Map<string, EditorState>());
|
||||
const scrollTopCacheRef = useRef(new Map<string, number>());
|
||||
const lruOrderRef = useRef<string[]>([]);
|
||||
|
||||
// Dirty flag debounce
|
||||
const dirtyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Autosave debounce
|
||||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Selection debounce
|
||||
const selectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const markFileModified = useStore((s) => s.markFileModified);
|
||||
const discardChanges = useStore((s) => s.discardChanges);
|
||||
const saveFile = useStore((s) => s.saveFile);
|
||||
const lineWrap = useStore((s) => s.editorLineWrap);
|
||||
|
||||
// Stable callbacks via refs to avoid extension recreation
|
||||
const filePathRef = useRef(filePath);
|
||||
filePathRef.current = filePath;
|
||||
|
||||
const onCursorChangeRef = useRef(onCursorChange);
|
||||
onCursorChangeRef.current = onCursorChange;
|
||||
|
||||
const onDraftRecoveredRef = useRef(onDraftRecovered);
|
||||
onDraftRecoveredRef.current = onDraftRecovered;
|
||||
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
|
||||
const lineWrapRef = useRef(lineWrap);
|
||||
lineWrapRef.current = lineWrap;
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
void saveFile(filePathRef.current);
|
||||
}, [saveFile]);
|
||||
|
||||
const handleDocChanged = useCallback(() => {
|
||||
// Debounced dirty flag
|
||||
if (dirtyTimerRef.current) clearTimeout(dirtyTimerRef.current);
|
||||
dirtyTimerRef.current = setTimeout(() => {
|
||||
markFileModified(filePathRef.current);
|
||||
}, DIRTY_DEBOUNCE_MS);
|
||||
|
||||
// Debounced autosave
|
||||
if (autosaveTimerRef.current) clearTimeout(autosaveTimerRef.current);
|
||||
autosaveTimerRef.current = setTimeout(() => {
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
saveDraft(filePathRef.current, view.state.doc.toString());
|
||||
}
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
}, [markFileModified]);
|
||||
|
||||
const handleCursorMove = useCallback((line: number, col: number) => {
|
||||
onCursorChangeRef.current?.(line, col);
|
||||
}, []);
|
||||
|
||||
const handleSelectionEmit = useCallback((info: EditorSelectionInfo | null) => {
|
||||
if (!info) {
|
||||
// Empty selection — clear immediately
|
||||
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
||||
onSelectionChangeRef.current?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-empty selection — debounce to prevent flicker during rapid selection changes
|
||||
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
||||
selectionTimerRef.current = setTimeout(() => {
|
||||
// Enrich with filePath (not available inside extension builder)
|
||||
onSelectionChangeRef.current?.({ ...info, filePath: filePathRef.current });
|
||||
}, SELECTION_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
// Immediate position update during scroll — no debounce to avoid menu drift
|
||||
const handleScrollReposition = useCallback((info: EditorSelectionInfo | null) => {
|
||||
if (selectionTimerRef.current) clearTimeout(selectionTimerRef.current);
|
||||
if (info) {
|
||||
onSelectionChangeRef.current?.({ ...info, filePath: filePathRef.current });
|
||||
} else {
|
||||
onSelectionChangeRef.current?.(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// LRU touch
|
||||
const touchLru = useCallback(
|
||||
(fp: string) => {
|
||||
const order = lruOrderRef.current;
|
||||
const idx = order.indexOf(fp);
|
||||
if (idx !== -1) order.splice(idx, 1);
|
||||
order.push(fp);
|
||||
|
||||
// Evict if too many
|
||||
while (order.length > MAX_CACHED_STATES) {
|
||||
const evicted = order.shift()!;
|
||||
stateCacheRef.current.delete(evicted);
|
||||
scrollTopCacheRef.current.delete(evicted);
|
||||
// Clean dirty flag + draft to prevent stale indicators
|
||||
discardChanges(evicted);
|
||||
}
|
||||
},
|
||||
[discardChanges]
|
||||
);
|
||||
|
||||
// Mount: create EditorView, register bridge
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const extensions = buildEditableExtensions(
|
||||
fileName,
|
||||
handleSave,
|
||||
handleDocChanged,
|
||||
handleCursorMove,
|
||||
handleSelectionEmit,
|
||||
handleScrollReposition
|
||||
);
|
||||
|
||||
// Line wrap (dynamically reconfigurable via Compartment)
|
||||
extensions.push(lineWrapCompartment.of(lineWrapRef.current ? EditorView.lineWrapping : []));
|
||||
|
||||
// Check for cached state or draft recovery
|
||||
let initialState = stateCacheRef.current.get(filePath);
|
||||
if (!initialState) {
|
||||
let initialContent = content;
|
||||
let draftRecovered = false;
|
||||
|
||||
// Draft recovery: compare draft.timestamp with file mtimeMs
|
||||
try {
|
||||
const draftJson = localStorage.getItem(`editor-draft:${filePath}`);
|
||||
if (draftJson) {
|
||||
const draft = JSON.parse(draftJson) as { content: string; timestamp: number };
|
||||
const fileMtime = mtimeMs ?? 0;
|
||||
|
||||
if (fileMtime === 0 || draft.timestamp > fileMtime) {
|
||||
// Draft is newer than file (or file is new) — recover draft
|
||||
initialContent = draft.content;
|
||||
draftRecovered = true;
|
||||
} else {
|
||||
// File was modified after draft — draft is stale, delete silently
|
||||
localStorage.removeItem(`editor-draft:${filePath}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
initialState = EditorState.create({
|
||||
doc: initialContent,
|
||||
extensions,
|
||||
});
|
||||
stateCacheRef.current.set(filePath, initialState);
|
||||
|
||||
// Signal draft recovery after state creation
|
||||
if (draftRecovered) {
|
||||
// Mark as modified so dirty indicator shows
|
||||
markFileModified(filePath);
|
||||
onDraftRecoveredRef.current?.(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
touchLru(filePath);
|
||||
|
||||
const view = new EditorView({
|
||||
state: initialState,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
// Restore scroll position
|
||||
const savedScroll = scrollTopCacheRef.current.get(filePath);
|
||||
if (savedScroll !== undefined) {
|
||||
view.scrollDOM.scrollTop = savedScroll;
|
||||
}
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
// Register with bridge
|
||||
editorBridge.register(stateCacheRef.current, scrollTopCacheRef.current, view);
|
||||
|
||||
// Report initial cursor position
|
||||
const pos = view.state.selection.main.head;
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
onCursorChangeRef.current?.(line.number, pos - line.from + 1);
|
||||
|
||||
// Capture ref values for cleanup — React hooks exhaustive-deps requires
|
||||
// refs used in cleanup to be captured in the effect body, not read
|
||||
// from .current inside the cleanup function.
|
||||
const scrollTopCache = scrollTopCacheRef.current;
|
||||
const stateCache = stateCacheRef.current;
|
||||
const dirtyTimer = dirtyTimerRef;
|
||||
const autosaveTimer = autosaveTimerRef;
|
||||
const selectionTimer = selectionTimerRef;
|
||||
|
||||
return () => {
|
||||
// Save scroll position before destroying
|
||||
scrollTopCache.set(filePath, view.scrollDOM.scrollTop);
|
||||
|
||||
// Save current state to cache
|
||||
stateCache.set(filePath, view.state);
|
||||
|
||||
// Clear timers
|
||||
if (dirtyTimer.current) clearTimeout(dirtyTimer.current);
|
||||
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
|
||||
if (selectionTimer.current) clearTimeout(selectionTimer.current);
|
||||
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentional: only re-mount when filePath changes (tab switch). Content/fileName changes with the same filePath should use the cached state.
|
||||
}, [filePath]);
|
||||
|
||||
// Sync line wrap setting dynamically (including cached states on tab switch)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: lineWrapCompartment.reconfigure(lineWrap ? EditorView.lineWrapping : []),
|
||||
});
|
||||
}, [lineWrap]);
|
||||
|
||||
// Cleanup bridge on full unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
editorBridge.unregister();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} className="size-full overflow-auto" />;
|
||||
};
|
||||
41
src/renderer/components/team/editor/EditorBinaryState.tsx
Normal file
41
src/renderer/components/team/editor/EditorBinaryState.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Placeholder for binary files — shows file info and "Open in System Viewer" button.
|
||||
*/
|
||||
|
||||
import { FileQuestion } from 'lucide-react';
|
||||
|
||||
interface EditorBinaryStateProps {
|
||||
filePath: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const EditorBinaryState = ({
|
||||
filePath,
|
||||
size,
|
||||
}: EditorBinaryStateProps): React.ReactElement => {
|
||||
const fileName = filePath.split('/').pop() ?? filePath;
|
||||
const sizeFormatted =
|
||||
size < 1024
|
||||
? `${size} B`
|
||||
: size < 1024 * 1024
|
||||
? `${(size / 1024).toFixed(1)} KB`
|
||||
: `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
const handleOpenExternal = (): void => {
|
||||
window.electronAPI.openPath(filePath).catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<FileQuestion className="size-12 opacity-30" />
|
||||
<p className="text-sm font-medium text-text-secondary">{fileName}</p>
|
||||
<p className="text-xs">Binary file ({sizeFormatted})</p>
|
||||
<button
|
||||
onClick={handleOpenExternal}
|
||||
className="mt-2 rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
Open in System Viewer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
src/renderer/components/team/editor/EditorBreadcrumb.tsx
Normal file
72
src/renderer/components/team/editor/EditorBreadcrumb.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Breadcrumb navigation for the active file in the editor.
|
||||
*
|
||||
* Each segment is clickable — expands and scrolls the folder in the file tree.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import { getFileIcon } from './fileIcons';
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorBreadcrumb = (): React.ReactElement | null => {
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const projectPath = useStore((s) => s.editorProjectPath);
|
||||
const expandDirectory = useStore((s) => s.expandDirectory);
|
||||
|
||||
const segments = useMemo(() => {
|
||||
if (!activeTabId || !projectPath) return [];
|
||||
|
||||
const relativePath = activeTabId.startsWith(projectPath)
|
||||
? activeTabId.slice(projectPath.length + 1)
|
||||
: activeTabId;
|
||||
|
||||
return relativePath.split('/');
|
||||
}, [activeTabId, projectPath]);
|
||||
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const fileName = segments[segments.length - 1];
|
||||
const iconInfo = getFileIcon(fileName);
|
||||
const Icon = iconInfo.icon;
|
||||
|
||||
const handleSegmentClick = (segmentIndex: number): void => {
|
||||
if (!projectPath) return;
|
||||
// Build absolute path up to this segment (it's a directory)
|
||||
const dirSegments = segments.slice(0, segmentIndex + 1);
|
||||
const dirPath = `${projectPath}/${dirSegments.join('/')}`;
|
||||
void expandDirectory(dirPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 overflow-x-auto px-3 py-1 text-xs text-text-muted">
|
||||
{segments.map((segment, idx) => {
|
||||
const isLast = idx === segments.length - 1;
|
||||
return (
|
||||
<span key={idx} className="flex shrink-0 items-center gap-0.5">
|
||||
{idx > 0 && <ChevronRight className="text-text-muted/50 size-3" />}
|
||||
{isLast ? (
|
||||
<span className="flex items-center gap-1 text-text-secondary">
|
||||
<Icon className="size-3" style={{ color: iconInfo.color }} />
|
||||
{segment}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSegmentClick(idx)}
|
||||
className="rounded px-0.5 transition-colors hover:bg-surface-raised hover:text-text-secondary"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
133
src/renderer/components/team/editor/EditorContextMenu.tsx
Normal file
133
src/renderer/components/team/editor/EditorContextMenu.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Radix-based context menu for the editor file tree.
|
||||
*
|
||||
* Wraps children via ContextMenu.Trigger asChild. Uses event delegation
|
||||
* with `data-editor-path` / `data-editor-type` attributes on tree items
|
||||
* to determine the right-clicked target.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import { FilePlus, FolderOpen, FolderPlus, Trash2 } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface TargetEntry {
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
isSensitive: boolean;
|
||||
}
|
||||
|
||||
interface EditorContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
onNewFile: (parentDir: string) => void;
|
||||
onNewFolder: (parentDir: string) => void;
|
||||
onDelete: (path: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorContextMenu = ({
|
||||
children,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onDelete,
|
||||
}: EditorContextMenuProps): React.ReactElement => {
|
||||
const [target, setTarget] = useState<TargetEntry | null>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
// Walk up from target to find the nearest element with data-editor-path
|
||||
let el = e.target as HTMLElement | null;
|
||||
while (el && el !== e.currentTarget) {
|
||||
const path = el.getAttribute('data-editor-path');
|
||||
if (path) {
|
||||
const type = el.getAttribute('data-editor-type');
|
||||
const sensitive = el.getAttribute('data-editor-sensitive');
|
||||
setTarget({
|
||||
path,
|
||||
isDir: type === 'directory',
|
||||
isSensitive: sensitive === 'true',
|
||||
});
|
||||
return;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
// Clicked on empty area — still show menu but with limited options
|
||||
setTarget(null);
|
||||
}, []);
|
||||
|
||||
const parentDir = target
|
||||
? target.isDir
|
||||
? target.path
|
||||
: target.path.substring(0, target.path.lastIndexOf('/'))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<div ref={triggerRef} onContextMenu={handleContextMenu}>
|
||||
{children}
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content className="z-50 min-w-[180px] rounded-md border border-border-emphasis bg-surface-overlay p-1 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95">
|
||||
{parentDir && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => onNewFile(parentDir)}
|
||||
>
|
||||
<FilePlus className="size-3.5 text-text-muted" />
|
||||
New File
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => onNewFolder(parentDir)}
|
||||
>
|
||||
<FolderPlus className="size-3.5 text-text-muted" />
|
||||
New Folder
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{target && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-red-400 outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={target.isSensitive}
|
||||
onSelect={() => onDelete(target.path)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</ContextMenu.Item>
|
||||
|
||||
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{target && (
|
||||
<ContextMenu.Item
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||
onSelect={() => {
|
||||
void window.electronAPI.showInFolder(target.path);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-3.5 text-text-muted" />
|
||||
Reveal in Finder
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
35
src/renderer/components/team/editor/EditorEmptyState.tsx
Normal file
35
src/renderer/components/team/editor/EditorEmptyState.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Empty state shown when no file is open in the editor.
|
||||
* Shows keyboard shortcuts cheatsheet.
|
||||
*/
|
||||
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import { FileCode } from 'lucide-react';
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ keys: shortcutLabel('⌘ P', 'Ctrl+P'), label: 'Quick Open' },
|
||||
{ keys: shortcutLabel('⌘ ⇧ F', 'Ctrl+Shift+F'), label: 'Search in Files' },
|
||||
{ keys: shortcutLabel('⌘ S', 'Ctrl+S'), label: 'Save' },
|
||||
{ keys: shortcutLabel('⌘ B', 'Ctrl+B'), label: 'Toggle Sidebar' },
|
||||
{ keys: shortcutLabel('⌘ G', 'Ctrl+G'), label: 'Go to Line' },
|
||||
{ keys: 'Esc', label: 'Close Editor' },
|
||||
];
|
||||
|
||||
export const EditorEmptyState = (): React.ReactElement => {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-text-muted">
|
||||
<FileCode className="size-12 opacity-30" />
|
||||
<p className="text-sm">Select a file from the tree to edit</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1.5">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<div key={s.keys} className="flex items-center justify-between gap-4 text-xs">
|
||||
<span className="text-text-muted">{s.label}</span>
|
||||
<kbd className="rounded border border-border bg-surface-raised px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">
|
||||
{s.keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
src/renderer/components/team/editor/EditorErrorBoundary.tsx
Normal file
58
src/renderer/components/team/editor/EditorErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* React error boundary wrapping CodeMirrorEditor.
|
||||
*
|
||||
* Catches runtime CM6 errors (OOM, bad extension, corrupted EditorState)
|
||||
* and shows a fallback UI instead of crashing the entire overlay.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
filePath: string;
|
||||
onRetry?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export class EditorErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error: error.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
console.error(`[EditorErrorBoundary] ${this.props.filePath}:`, error, info.componentStack);
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.props.onRetry?.();
|
||||
};
|
||||
|
||||
render(): React.ReactElement {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<AlertTriangle className="size-12 text-red-400 opacity-50" />
|
||||
<p className="max-w-md text-center text-sm text-text-secondary">
|
||||
Editor crashed: {this.state.error ?? 'Unknown error'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{this.props.children}</>;
|
||||
}
|
||||
}
|
||||
42
src/renderer/components/team/editor/EditorErrorState.tsx
Normal file
42
src/renderer/components/team/editor/EditorErrorState.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Error state for file read failures (EACCES, ENOENT, etc.).
|
||||
*/
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface EditorErrorStateProps {
|
||||
error: string;
|
||||
onRetry?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const EditorErrorState = ({
|
||||
error,
|
||||
onRetry,
|
||||
onClose,
|
||||
}: EditorErrorStateProps): React.ReactElement => {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
|
||||
<AlertTriangle className="size-12 text-yellow-500 opacity-50" />
|
||||
<p className="max-w-md text-center text-sm text-text-secondary">{error}</p>
|
||||
<div className="flex gap-2">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
Close Tab
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
617
src/renderer/components/team/editor/EditorFileTree.tsx
Normal file
617
src/renderer/components/team/editor/EditorFileTree.tsx
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
/**
|
||||
* Editor file tree — virtualized with @tanstack/react-virtual.
|
||||
*
|
||||
* Renders project files with file-type icons, sensitive-file lock icons,
|
||||
* directory expand/collapse, context menu, inline file creation, and drag & drop.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
|
||||
|
||||
import { EditorContextMenu } from './EditorContextMenu';
|
||||
import { getFileIcon } from './fileIcons';
|
||||
import { GitStatusBadge } from './GitStatusBadge';
|
||||
import { NewFileDialog } from './NewFileDialog';
|
||||
|
||||
import type { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import type { TreeNode } from '@renderer/utils/fileTreeBuilder';
|
||||
import type { FileTreeEntry, GitFileStatusType } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface EditorFileTreeProps {
|
||||
selectedFilePath: string | null;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface NewItemState {
|
||||
parentDir: string;
|
||||
type: 'file' | 'directory';
|
||||
}
|
||||
|
||||
/** Flat item for virtualization */
|
||||
interface FlatTreeItem {
|
||||
node: TreeNode<FileTreeEntry>;
|
||||
depth: number;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const ITEM_HEIGHT = 28;
|
||||
const INDENT_PX = 12;
|
||||
const MAX_DEPTH = 12;
|
||||
const AUTO_EXPAND_DELAY_MS = 500;
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorFileTree = ({
|
||||
selectedFilePath,
|
||||
onFileSelect,
|
||||
}: EditorFileTreeProps): React.ReactElement => {
|
||||
const fileTree = useStore((s) => s.editorFileTree);
|
||||
const expandedDirs = useStore((s) => s.editorExpandedDirs);
|
||||
const expandDirectory = useStore((s) => s.expandDirectory);
|
||||
const collapseDirectory = useStore((s) => s.collapseDirectory);
|
||||
const loading = useStore((s) => s.editorFileTreeLoading);
|
||||
const error = useStore((s) => s.editorFileTreeError);
|
||||
const createFileInTree = useStore((s) => s.createFileInTree);
|
||||
const createDirInTree = useStore((s) => s.createDirInTree);
|
||||
const deleteFileFromTree = useStore((s) => s.deleteFileFromTree);
|
||||
const moveFileInTree = useStore((s) => s.moveFileInTree);
|
||||
const openFile = useStore((s) => s.openFile);
|
||||
const gitFiles = useStore((s) => s.editorGitFiles);
|
||||
const projectPath = useStore((s) => s.editorProjectPath);
|
||||
|
||||
const [newItemState, setNewItemState] = useState<NewItemState | null>(null);
|
||||
const [draggedItem, setDraggedItem] = useState<FlatTreeItem | null>(null);
|
||||
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null);
|
||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Cleanup auto-expand timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// DnD sensors — 5px distance to prevent accidental drags
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
|
||||
// Convert hierarchical FileTreeEntry[] → TreeNode[] (respects entry.type)
|
||||
const treeNodes = useMemo(() => {
|
||||
if (!fileTree) return [];
|
||||
return sortTreeNodes(convertEntriesToNodes(fileTree));
|
||||
}, [fileTree]);
|
||||
|
||||
// Flatten tree into visible items list for virtualization
|
||||
// expandedDirs is keyed by absolute path, and node.fullPath = entry.path (absolute)
|
||||
const flatItems = useMemo(() => {
|
||||
const items: FlatTreeItem[] = [];
|
||||
flattenVisible(treeNodes, expandedDirs, items, 0);
|
||||
return items;
|
||||
}, [treeNodes, expandedDirs]);
|
||||
|
||||
// Lookup: fullPath → FlatTreeItem (for drag start)
|
||||
const flatItemsByPath = useMemo(() => {
|
||||
const map = new Map<string, FlatTreeItem>();
|
||||
for (const item of flatItems) {
|
||||
map.set(item.node.fullPath, item);
|
||||
}
|
||||
return map;
|
||||
}, [flatItems]);
|
||||
|
||||
// Virtual scrolling — increase overscan during drag for more drop targets
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: draggedItem ? 20 : 10,
|
||||
});
|
||||
|
||||
// Git status lookup: absolute path → status type
|
||||
const gitStatusMap = useMemo(() => {
|
||||
const map = new Map<string, GitFileStatusType>();
|
||||
if (!gitFiles.length || !projectPath) return map;
|
||||
for (const file of gitFiles) {
|
||||
const absPath = projectPath.endsWith('/')
|
||||
? `${projectPath}${file.path}`
|
||||
: `${projectPath}/${file.path}`;
|
||||
map.set(absPath, file.status);
|
||||
}
|
||||
return map;
|
||||
}, [gitFiles, projectPath]);
|
||||
|
||||
// Active node path for selection highlight (fullPath = absolute path)
|
||||
const activeNodePath = selectedFilePath;
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(node: TreeNode<FileTreeEntry>) => {
|
||||
if (!node.data) return;
|
||||
if (node.data.isSensitive) return;
|
||||
if (node.isFile) {
|
||||
onFileSelect(node.data.path);
|
||||
} else {
|
||||
// fullPath = absolute path = entry.path
|
||||
if (expandedDirs[node.fullPath]) {
|
||||
collapseDirectory(node.fullPath);
|
||||
} else {
|
||||
void expandDirectory(node.fullPath);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onFileSelect, expandedDirs, expandDirectory, collapseDirectory]
|
||||
);
|
||||
|
||||
// Context menu handlers
|
||||
const handleNewFile = useCallback((parentDir: string) => {
|
||||
setNewItemState({ parentDir, type: 'file' });
|
||||
}, []);
|
||||
|
||||
const handleNewFolder = useCallback((parentDir: string) => {
|
||||
setNewItemState({ parentDir, type: 'directory' });
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (path: string) => {
|
||||
const fileName = path.split('/').pop() ?? path;
|
||||
const confirmed = window.confirm(`Move "${fileName}" to Trash?`);
|
||||
if (!confirmed) return;
|
||||
await deleteFileFromTree(path);
|
||||
},
|
||||
[deleteFileFromTree]
|
||||
);
|
||||
|
||||
const handleNewItemSubmit = useCallback(
|
||||
async (name: string) => {
|
||||
if (!newItemState) return;
|
||||
if (newItemState.type === 'file') {
|
||||
const filePath = await createFileInTree(newItemState.parentDir, name);
|
||||
if (filePath) openFile(filePath);
|
||||
} else {
|
||||
await createDirInTree(newItemState.parentDir, name);
|
||||
}
|
||||
setNewItemState(null);
|
||||
},
|
||||
[newItemState, createFileInTree, createDirInTree, openFile]
|
||||
);
|
||||
|
||||
const handleNewItemCancel = useCallback(() => {
|
||||
setNewItemState(null);
|
||||
}, []);
|
||||
|
||||
// ─── Drag & Drop handlers ──────────────────────────────────────────────────
|
||||
|
||||
const clearAutoExpandTimer = useCallback(() => {
|
||||
if (autoExpandTimerRef.current) {
|
||||
clearTimeout(autoExpandTimerRef.current);
|
||||
autoExpandTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const id = String(event.active.id);
|
||||
const item = flatItemsByPath.get(id);
|
||||
if (item) setDraggedItem(item);
|
||||
},
|
||||
[flatItemsByPath]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { over } = event;
|
||||
if (!over || !draggedItem) {
|
||||
setDropTargetPath(null);
|
||||
clearAutoExpandTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const overId = String(over.id);
|
||||
let targetDir: string | null = null;
|
||||
|
||||
if (overId === 'root-drop-zone') {
|
||||
targetDir = projectPath;
|
||||
} else if (overId.startsWith('drop:')) {
|
||||
// Directory drop target
|
||||
targetDir = overId.slice(5);
|
||||
} else {
|
||||
// File — drop into its parent directory
|
||||
const item = flatItemsByPath.get(overId);
|
||||
if (item) {
|
||||
const p = item.node.fullPath;
|
||||
targetDir = p.substring(0, p.lastIndexOf('/'));
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDir !== dropTargetPath) {
|
||||
setDropTargetPath(targetDir);
|
||||
clearAutoExpandTimer();
|
||||
|
||||
// Auto-expand collapsed folders after 500ms hover
|
||||
if (targetDir && targetDir !== projectPath && !expandedDirs[targetDir]) {
|
||||
autoExpandTimerRef.current = setTimeout(() => {
|
||||
void expandDirectory(targetDir);
|
||||
}, AUTO_EXPAND_DELAY_MS);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
draggedItem,
|
||||
dropTargetPath,
|
||||
projectPath,
|
||||
flatItemsByPath,
|
||||
expandedDirs,
|
||||
expandDirectory,
|
||||
clearAutoExpandTimer,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
clearAutoExpandTimer();
|
||||
const sourcePath = draggedItem?.node.fullPath;
|
||||
|
||||
if (!sourcePath || !dropTargetPath || !event.over) {
|
||||
setDraggedItem(null);
|
||||
setDropTargetPath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const destDir = dropTargetPath;
|
||||
const sourceParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
||||
|
||||
// Validation: same folder = no-op
|
||||
if (sourceParent === destDir) {
|
||||
setDraggedItem(null);
|
||||
setDropTargetPath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: parent → child prevention
|
||||
if (destDir.startsWith(sourcePath + '/') || destDir === sourcePath) {
|
||||
setDraggedItem(null);
|
||||
setDropTargetPath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: sensitive files
|
||||
if (draggedItem?.node.data?.isSensitive) {
|
||||
setDraggedItem(null);
|
||||
setDropTargetPath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void moveFileInTree(sourcePath, destDir);
|
||||
|
||||
setDraggedItem(null);
|
||||
setDropTargetPath(null);
|
||||
},
|
||||
[draggedItem, dropTargetPath, moveFileInTree, clearAutoExpandTimer]
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
clearAutoExpandTimer();
|
||||
setDraggedItem(null);
|
||||
setDropTargetPath(null);
|
||||
}, [clearAutoExpandTimer]);
|
||||
|
||||
// ─── Early returns ─────────────────────────────────────────────────────────
|
||||
|
||||
if (error) {
|
||||
return <div className="p-3 text-xs text-red-400">Failed to load files: {error}</div>;
|
||||
}
|
||||
|
||||
if (loading && !fileTree) {
|
||||
return <div className="p-3 text-xs text-text-muted">Loading files...</div>;
|
||||
}
|
||||
|
||||
if (treeNodes.length === 0) {
|
||||
return <div className="p-3 text-xs text-text-muted">No files found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorContextMenu
|
||||
onNewFile={handleNewFile}
|
||||
onNewFolder={handleNewFolder}
|
||||
onDelete={handleDelete}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
autoScroll={{ threshold: { x: 0, y: 0.15 } }}
|
||||
>
|
||||
<RootDropZone ref={scrollRef} projectPath={projectPath}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const item = flatItems[virtualItem.index];
|
||||
return (
|
||||
<DraggableTreeItem
|
||||
key={item.node.fullPath}
|
||||
item={item}
|
||||
activeNodePath={activeNodePath}
|
||||
gitStatusMap={gitStatusMap}
|
||||
dropTargetPath={dropTargetPath}
|
||||
isDragActive={!!draggedItem}
|
||||
onClick={handleNodeClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RootDropZone>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{newItemState && (
|
||||
<NewFileDialog
|
||||
type={newItemState.type}
|
||||
parentDir={newItemState.parentDir}
|
||||
onSubmit={handleNewItemSubmit}
|
||||
onCancel={handleNewItemCancel}
|
||||
/>
|
||||
)}
|
||||
</EditorContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Root drop zone (drop files to project root)
|
||||
// =============================================================================
|
||||
|
||||
const RootDropZone = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{ projectPath: string | null; children: React.ReactNode }
|
||||
>(({ projectPath, children }, ref) => {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: 'root-drop-zone',
|
||||
data: { isRoot: true, path: projectPath },
|
||||
});
|
||||
|
||||
// Combine forwarded ref with droppable ref
|
||||
const combinedRef = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
setNodeRef(el);
|
||||
if (typeof ref === 'function') ref(el);
|
||||
// eslint-disable-next-line no-param-reassign -- combining forwarded ref with droppable ref
|
||||
else if (ref) ref.current = el;
|
||||
},
|
||||
[ref, setNodeRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={combinedRef} className="h-full overflow-y-auto" role="tree">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
RootDropZone.displayName = 'RootDropZone';
|
||||
|
||||
// =============================================================================
|
||||
// Draggable + droppable tree item
|
||||
// =============================================================================
|
||||
|
||||
interface DraggableTreeItemProps {
|
||||
item: FlatTreeItem;
|
||||
activeNodePath: string | null;
|
||||
gitStatusMap: Map<string, GitFileStatusType>;
|
||||
dropTargetPath: string | null;
|
||||
isDragActive: boolean;
|
||||
onClick: (node: TreeNode<FileTreeEntry>) => void;
|
||||
style: React.CSSProperties;
|
||||
}
|
||||
|
||||
/* eslint-disable react/jsx-props-no-spreading -- dnd-kit requires prop spreading for drag attributes, listeners, and data attributes */
|
||||
const DraggableTreeItem = React.memo(
|
||||
({
|
||||
item,
|
||||
activeNodePath,
|
||||
gitStatusMap,
|
||||
dropTargetPath,
|
||||
isDragActive,
|
||||
onClick,
|
||||
style,
|
||||
}: DraggableTreeItemProps): React.ReactElement => {
|
||||
const { node, depth, isExpanded } = item;
|
||||
const isSelected = activeNodePath === node.fullPath;
|
||||
const visualDepth = Math.min(depth, MAX_DEPTH);
|
||||
const isSensitive = node.data?.isSensitive;
|
||||
|
||||
// Draggable setup
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDragRef,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: node.fullPath,
|
||||
data: { node, depth },
|
||||
disabled: !!isSensitive,
|
||||
});
|
||||
|
||||
// Droppable setup — only directories are drop targets
|
||||
const { setNodeRef: setDropRef } = useDroppable({
|
||||
id: 'drop:' + node.fullPath,
|
||||
data: { node },
|
||||
disabled: node.isFile,
|
||||
});
|
||||
|
||||
// Combine refs
|
||||
const ref = useCallback(
|
||||
(el: HTMLDivElement | null) => {
|
||||
setDragRef(el);
|
||||
if (!node.isFile) setDropRef(el);
|
||||
},
|
||||
[setDragRef, setDropRef, node.isFile]
|
||||
);
|
||||
|
||||
// Visual: highlight drop target directory
|
||||
const isDropTarget = !node.isFile && dropTargetPath === node.fullPath;
|
||||
|
||||
const dataAttrs: Record<string, string> = {};
|
||||
if (node.data) {
|
||||
dataAttrs['data-editor-path'] = node.data.path;
|
||||
dataAttrs['data-editor-type'] = node.data.type;
|
||||
if (node.data.isSensitive) dataAttrs['data-editor-sensitive'] = 'true';
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (!isDragActive) onClick(node);
|
||||
};
|
||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
// Render icon
|
||||
let icon: React.ReactNode;
|
||||
if (node.data?.isSensitive) {
|
||||
icon = <Lock className="size-3.5 shrink-0 text-yellow-500" />;
|
||||
} else if (node.isFile) {
|
||||
const fileIcon = getFileIcon(node.name);
|
||||
const FileIcon = fileIcon.icon;
|
||||
icon = <FileIcon className="size-3.5 shrink-0" style={{ color: fileIcon.color }} />;
|
||||
} else if (isExpanded) {
|
||||
icon = <FolderOpen className="size-3.5 shrink-0 text-text-muted" />;
|
||||
} else {
|
||||
icon = <Folder className="size-3.5 shrink-0 text-text-muted" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
role="treeitem"
|
||||
aria-selected={node.isFile ? isSelected : undefined}
|
||||
aria-expanded={!node.isFile ? isExpanded : undefined}
|
||||
className={`flex cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${
|
||||
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
|
||||
} ${isDragging ? 'opacity-30' : ''} ${
|
||||
isDropTarget ? 'rounded bg-blue-400/10 ring-2 ring-blue-400/50' : ''
|
||||
}`}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft: `${visualDepth * INDENT_PX + 8}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
title={node.data?.path ?? node.fullPath}
|
||||
{...dataAttrs}
|
||||
>
|
||||
{!node.isFile &&
|
||||
(isExpanded ? (
|
||||
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
||||
))}
|
||||
{icon}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.data && gitStatusMap.has(node.data.path) && (
|
||||
<GitStatusBadge status={gitStatusMap.get(node.data.path)!} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DraggableTreeItem.displayName = 'DraggableTreeItem';
|
||||
/* eslint-enable react/jsx-props-no-spreading -- re-enable after DraggableTreeItem component */
|
||||
|
||||
// =============================================================================
|
||||
// Drag overlay ghost
|
||||
// =============================================================================
|
||||
|
||||
const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactElement => {
|
||||
const { node } = item;
|
||||
|
||||
let icon: React.ReactNode;
|
||||
if (node.isFile) {
|
||||
const fileIcon = getFileIcon(node.name);
|
||||
const FileIcon = fileIcon.icon;
|
||||
icon = <FileIcon className="size-3.5" style={{ color: fileIcon.color }} />;
|
||||
} else {
|
||||
icon = <FolderOpen className="size-3.5 text-text-muted" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded border border-border-emphasis bg-surface-overlay px-3 py-1 text-xs text-text shadow-lg">
|
||||
{icon}
|
||||
<span className="truncate">{node.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/** Convert hierarchical FileTreeEntry[] into TreeNode[] using entry.type for classification */
|
||||
function convertEntriesToNodes(entries: FileTreeEntry[]): TreeNode<FileTreeEntry>[] {
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
fullPath: entry.path, // absolute path — matches expandedDirs keys
|
||||
isFile: entry.type === 'file',
|
||||
data: entry,
|
||||
children: entry.children ? convertEntriesToNodes(entry.children) : [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** Flatten tree into visible items list (DFS, respecting expanded state) */
|
||||
function flattenVisible(
|
||||
nodes: TreeNode<FileTreeEntry>[],
|
||||
expandedPaths: Record<string, boolean>,
|
||||
result: FlatTreeItem[],
|
||||
depth: number
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
const isExpanded = !node.isFile && expandedPaths[node.fullPath] === true;
|
||||
result.push({ node, depth, isExpanded });
|
||||
if (isExpanded && node.children.length > 0) {
|
||||
flattenVisible(node.children, expandedPaths, result, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/renderer/components/team/editor/EditorSelectionMenu.tsx
Normal file
110
src/renderer/components/team/editor/EditorSelectionMenu.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Floating action menu shown near text selection in the editor.
|
||||
*
|
||||
* Positioned absolutely relative to the editor content container.
|
||||
* Uses onMouseDown preventDefault to avoid deselecting text in CM6.
|
||||
*/
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { ListTodo, MessageSquare } from 'lucide-react';
|
||||
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface EditorSelectionMenuProps {
|
||||
info: EditorSelectionInfo;
|
||||
/** Bounding rect of the editor content container (for viewport → container conversion) */
|
||||
containerRect: DOMRect;
|
||||
onSendMessage: () => void;
|
||||
onCreateTask: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const MENU_GAP = 8; // px gap between selection end and menu
|
||||
const MENU_WIDTH = 68; // approximate menu width for clamping
|
||||
const MENU_HEIGHT = 32; // approximate menu height for clamping
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorSelectionMenu = ({
|
||||
info,
|
||||
containerRect,
|
||||
onSendMessage,
|
||||
onCreateTask,
|
||||
}: EditorSelectionMenuProps): React.ReactElement | null => {
|
||||
if (!info.text.trim()) return null;
|
||||
|
||||
// Convert viewport coords → container-relative
|
||||
const rawTop = info.screenRect.top - containerRect.top;
|
||||
const rawLeft = info.screenRect.right - containerRect.left + MENU_GAP;
|
||||
|
||||
// Check if selection is within visible container bounds
|
||||
const selTopInContainer = info.screenRect.top - containerRect.top;
|
||||
const selBottomInContainer = info.screenRect.bottom - containerRect.top;
|
||||
if (selBottomInContainer < 0 || selTopInContainer > containerRect.height) {
|
||||
return null; // selection is scrolled out of view
|
||||
}
|
||||
|
||||
// Clamp to container bounds
|
||||
const top = Math.max(0, Math.min(rawTop, containerRect.height - MENU_HEIGHT));
|
||||
const left =
|
||||
rawLeft + MENU_WIDTH > containerRect.width
|
||||
? info.screenRect.right - containerRect.left - MENU_WIDTH - MENU_GAP // flip to left side
|
||||
: rawLeft;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto absolute z-20 flex items-center gap-0.5 rounded-md border border-border-emphasis bg-surface-overlay p-0.5 shadow-lg animate-in fade-in-0 zoom-in-95"
|
||||
style={{ top, left: Math.max(0, left) }}
|
||||
>
|
||||
<MenuButton
|
||||
icon={<MessageSquare className="size-3.5" />}
|
||||
label="Write Teammate"
|
||||
onClick={onSendMessage}
|
||||
/>
|
||||
<MenuButton
|
||||
icon={<ListTodo className="size-3.5" />}
|
||||
label="Create Task"
|
||||
onClick={onCreateTask}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Menu button
|
||||
// =============================================================================
|
||||
|
||||
interface MenuButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const MenuButton = ({ icon, label, onClick }: MenuButtonProps): React.ReactElement => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
onMouseDown={(e) => e.preventDefault()} // prevent CM6 selection loss
|
||||
className="rounded p-1.5 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
142
src/renderer/components/team/editor/EditorShortcutsHelp.tsx
Normal file
142
src/renderer/components/team/editor/EditorShortcutsHelp.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Keyboard shortcuts help modal for the project editor.
|
||||
*
|
||||
* Cross-platform: detects Mac vs Windows/Linux and shows
|
||||
* the appropriate modifier symbols.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { IS_MAC } from '@renderer/utils/platformKeys';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface EditorShortcutsHelpProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ShortcutDef {
|
||||
mac: string;
|
||||
other: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Shortcuts data
|
||||
// =============================================================================
|
||||
|
||||
const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
|
||||
{
|
||||
title: 'File Operations',
|
||||
shortcuts: [
|
||||
{ mac: '⌘ P', other: 'Ctrl+P', description: 'Quick Open' },
|
||||
{ mac: '⌘ S', other: 'Ctrl+S', description: 'Save' },
|
||||
{ mac: '⌘ ⇧ S', other: 'Ctrl+Shift+S', description: 'Save All' },
|
||||
{ mac: '⌘ W', other: 'Ctrl+W', description: 'Close Tab' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
shortcuts: [
|
||||
{ mac: '⌘ F', other: 'Ctrl+F', description: 'Find in File' },
|
||||
{ mac: '⌘ ⇧ F', other: 'Ctrl+Shift+F', description: 'Search in Files' },
|
||||
{ mac: '⌘ G', other: 'Ctrl+G', description: 'Go to Line' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{ mac: '⌘ ⇧ ]', other: 'Ctrl+Shift+]', description: 'Next Tab' },
|
||||
{ mac: '⌘ ⇧ [', other: 'Ctrl+Shift+[', description: 'Previous Tab' },
|
||||
{ mac: '⌃ Tab', other: 'Ctrl+Tab', description: 'Cycle Tabs' },
|
||||
{ mac: '⌘ B', other: 'Ctrl+B', description: 'Toggle Sidebar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Editing',
|
||||
shortcuts: [
|
||||
{ mac: '⌘ Z', other: 'Ctrl+Z', description: 'Undo' },
|
||||
{ mac: '⌘ ⇧ Z', other: 'Ctrl+Y', description: 'Redo' },
|
||||
{ mac: '⌘ D', other: 'Ctrl+D', description: 'Select Next Match' },
|
||||
{ mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): React.ReactElement => {
|
||||
// Escape closes help (capture phase)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
// Resolve platform-specific keys once
|
||||
const resolvedGroups = useMemo(
|
||||
() =>
|
||||
SHORTCUT_GROUPS.map((group) => ({
|
||||
...group,
|
||||
shortcuts: group.shortcuts.map((s) => ({
|
||||
keys: IS_MAC ? s.mac : s.other,
|
||||
description: s.description,
|
||||
})),
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative z-10 w-[480px] rounded-lg border border-border-emphasis bg-surface p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-text">Keyboard Shortcuts</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
|
||||
{resolvedGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="mb-1.5 text-xs font-medium text-text-secondary">{group.title}</h3>
|
||||
<div className="space-y-1">
|
||||
{group.shortcuts.map((shortcut) => (
|
||||
<div key={shortcut.keys} className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-muted">{shortcut.description}</span>
|
||||
<kbd className="rounded border border-border bg-surface-raised px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">
|
||||
{shortcut.keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
src/renderer/components/team/editor/EditorStatusBar.tsx
Normal file
48
src/renderer/components/team/editor/EditorStatusBar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Status bar: cursor position, language, encoding, indent style, git branch.
|
||||
*/
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
line: number;
|
||||
col: number;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export const EditorStatusBar = ({
|
||||
line,
|
||||
col,
|
||||
language,
|
||||
}: EditorStatusBarProps): React.ReactElement => {
|
||||
const gitBranch = useStore((s) => s.editorGitBranch);
|
||||
const isGitRepo = useStore((s) => s.editorIsGitRepo);
|
||||
const watcherEnabled = useStore((s) => s.editorWatcherEnabled);
|
||||
|
||||
return (
|
||||
<div className="flex h-6 shrink-0 items-center justify-between border-t border-border bg-surface-sidebar px-3 text-[11px] text-text-muted">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
Ln {line}, Col {col}
|
||||
</span>
|
||||
{isGitRepo && gitBranch && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="size-3" />
|
||||
{gitBranch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{watcherEnabled && (
|
||||
<span className="text-green-400" title="File watcher active">
|
||||
watching
|
||||
</span>
|
||||
)}
|
||||
<span>{language}</span>
|
||||
<span>UTF-8</span>
|
||||
<span>Spaces: 2</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
125
src/renderer/components/team/editor/EditorTabBar.tsx
Normal file
125
src/renderer/components/team/editor/EditorTabBar.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Tab bar for the project editor.
|
||||
* Shows open files as tabs with dirty indicator (dot) and close button.
|
||||
*/
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { getFileIcon } from './fileIcons';
|
||||
|
||||
import type { EditorFileTab } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface EditorTabBarProps {
|
||||
/** Called instead of direct closeTab — allows parent to intercept dirty tabs */
|
||||
onRequestCloseTab: (tabId: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorTabBar = ({
|
||||
onRequestCloseTab,
|
||||
}: EditorTabBarProps): React.ReactElement | null => {
|
||||
const tabs = useStore((s) => s.editorOpenTabs);
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
||||
const setActiveTab = useStore((s) => s.setActiveTab);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isModified={!!modifiedFiles[tab.filePath]}
|
||||
onActivate={() => setActiveTab(tab.id)}
|
||||
onClose={() => onRequestCloseTab(tab.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tab item
|
||||
// =============================================================================
|
||||
|
||||
interface TabProps {
|
||||
tab: EditorFileTab;
|
||||
isActive: boolean;
|
||||
isModified: boolean;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Tab = ({ tab, isActive, isModified, onActivate, onClose }: TabProps): React.ReactElement => {
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAuxClick = (e: React.MouseEvent) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const iconInfo = getFileIcon(tab.fileName);
|
||||
const FileIcon = iconInfo.icon;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
onAuxClick={handleAuxClick}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`group flex h-full shrink-0 items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
|
||||
isActive
|
||||
? 'bg-surface text-text'
|
||||
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{isModified && (
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-400"
|
||||
aria-label="Unsaved changes"
|
||||
/>
|
||||
)}
|
||||
<FileIcon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
|
||||
<span className="max-w-40 truncate">
|
||||
{tab.fileName}
|
||||
{tab.disambiguatedLabel && (
|
||||
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleClose}
|
||||
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
|
||||
role="button"
|
||||
aria-label={`Close ${tab.fileName}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
115
src/renderer/components/team/editor/EditorToolbar.tsx
Normal file
115
src/renderer/components/team/editor/EditorToolbar.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Toolbar with Save, Undo, Redo buttons.
|
||||
*/
|
||||
|
||||
import { redo, undo } from '@codemirror/commands';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import { Redo2, Save, Undo2, WrapText } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorToolbar = (): React.ReactElement | null => {
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
||||
const saving = useStore((s) => s.editorSaving);
|
||||
const saveFile = useStore((s) => s.saveFile);
|
||||
const lineWrap = useStore((s) => s.editorLineWrap);
|
||||
const toggleLineWrap = useStore((s) => s.toggleLineWrap);
|
||||
|
||||
if (!activeTabId) return null;
|
||||
|
||||
const isDirty = !!modifiedFiles[activeTabId];
|
||||
const isSaving = !!saving[activeTabId];
|
||||
|
||||
const handleSave = () => {
|
||||
void saveFile(activeTabId);
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
const view = editorBridge.getView();
|
||||
if (view) undo(view);
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
const view = editorBridge.getView();
|
||||
if (view) redo(view);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-8 shrink-0 items-center gap-1 border-b border-border bg-surface px-2">
|
||||
<ToolbarButton
|
||||
icon={<Save className="size-3.5" />}
|
||||
label="Save"
|
||||
shortcut={shortcutLabel('⌘ S', 'Ctrl+S')}
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || isSaving}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Undo2 className="size-3.5" />}
|
||||
label="Undo"
|
||||
shortcut={shortcutLabel('⌘ Z', 'Ctrl+Z')}
|
||||
onClick={handleUndo}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Redo2 className="size-3.5" />}
|
||||
label="Redo"
|
||||
shortcut={shortcutLabel('⌘ ⇧ Z', 'Ctrl+Y')}
|
||||
onClick={handleRedo}
|
||||
/>
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
icon={<WrapText className="size-3.5" />}
|
||||
label={lineWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
shortcut={shortcutLabel('⌘ ⇧ W', 'Ctrl+Shift+W')}
|
||||
onClick={toggleLineWrap}
|
||||
active={lineWrap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Toolbar button
|
||||
// =============================================================================
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const ToolbarButton = ({
|
||||
icon,
|
||||
label,
|
||||
shortcut,
|
||||
onClick,
|
||||
disabled = false,
|
||||
active = false,
|
||||
}: ToolbarButtonProps): React.ReactElement => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors hover:bg-surface-raised hover:text-text disabled:opacity-40 disabled:hover:bg-transparent ${
|
||||
active ? 'bg-surface-raised text-text' : 'text-text-muted'
|
||||
}`}
|
||||
aria-label={`${label} (${shortcut})`}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{label} ({shortcut})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
43
src/renderer/components/team/editor/GitStatusBadge.tsx
Normal file
43
src/renderer/components/team/editor/GitStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Git status badge for file tree entries.
|
||||
*
|
||||
* Shows single-letter indicators:
|
||||
* - M (modified) — orange
|
||||
* - U (untracked) — green
|
||||
* - A (staged/added) — green
|
||||
* - D (deleted) — red
|
||||
* - C (conflict) — red, bold
|
||||
* - R (renamed) — cyan
|
||||
*/
|
||||
|
||||
import type { GitFileStatusType } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Badge config
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_CONFIG: Record<GitFileStatusType, { letter: string; color: string }> = {
|
||||
modified: { letter: 'M', color: 'text-orange-400' },
|
||||
untracked: { letter: 'U', color: 'text-green-400' },
|
||||
staged: { letter: 'A', color: 'text-green-400' },
|
||||
deleted: { letter: 'D', color: 'text-red-400' },
|
||||
conflict: { letter: 'C', color: 'text-red-400 font-bold' },
|
||||
renamed: { letter: 'R', color: 'text-cyan-400' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
interface GitStatusBadgeProps {
|
||||
status: GitFileStatusType;
|
||||
}
|
||||
|
||||
export const GitStatusBadge = ({ status }: GitStatusBadgeProps): React.ReactElement => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
return (
|
||||
<span className={`ml-auto shrink-0 text-[10px] leading-none ${config.color}`} title={status}>
|
||||
{config.letter}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
106
src/renderer/components/team/editor/NewFileDialog.tsx
Normal file
106
src/renderer/components/team/editor/NewFileDialog.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Inline input for creating a new file or directory in the file tree.
|
||||
*
|
||||
* Auto-focuses, validates on the client side, submits on Enter, cancels on Escape/blur.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { FilePlus, FolderPlus } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface NewFileDialogProps {
|
||||
type: 'file' | 'directory';
|
||||
parentDir: string;
|
||||
onSubmit: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Validation
|
||||
// =============================================================================
|
||||
|
||||
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
|
||||
const INVALID_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
|
||||
|
||||
function validateName(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) return 'Name cannot be empty';
|
||||
if (trimmed === '.' || trimmed === '..') return 'Invalid name';
|
||||
if (INVALID_CHARS.test(trimmed)) return 'Name contains invalid characters';
|
||||
if (trimmed.length > 255) return 'Name is too long';
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const NewFileDialog = ({
|
||||
type,
|
||||
parentDir: _parentDir,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: NewFileDialogProps): React.ReactElement => {
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-focus on mount
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
const validationError = validateName(trimmed);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
onSubmit(trimmed);
|
||||
}, [value, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[handleSubmit, onCancel]
|
||||
);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const Icon = type === 'file' ? FilePlus : FolderPlus;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-2 py-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-text-muted" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={onCancel}
|
||||
placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
|
||||
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
|
||||
aria-label={type === 'file' ? 'New file name' : 'New folder name'}
|
||||
/>
|
||||
</div>
|
||||
{error && <span className="mt-0.5 pl-5 text-[10px] text-red-400">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
766
src/renderer/components/team/editor/ProjectEditorOverlay.tsx
Normal file
766
src/renderer/components/team/editor/ProjectEditorOverlay.tsx
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
/**
|
||||
* Full-screen project editor overlay.
|
||||
*
|
||||
* Pattern: follows ChangeReviewDialog.tsx — raw <div> with fixed inset-0, not Radix Dialog.
|
||||
* macOS traffic light padding, inert on background, Escape to close.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import {
|
||||
AlertTriangle,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||
import { EditorBinaryState } from './EditorBinaryState';
|
||||
import { EditorEmptyState } from './EditorEmptyState';
|
||||
import { EditorErrorBoundary } from './EditorErrorBoundary';
|
||||
import { EditorErrorState } from './EditorErrorState';
|
||||
import { EditorFileTree } from './EditorFileTree';
|
||||
import { EditorSelectionMenu } from './EditorSelectionMenu';
|
||||
import { EditorShortcutsHelp } from './EditorShortcutsHelp';
|
||||
import { EditorStatusBar } from './EditorStatusBar';
|
||||
import { EditorTabBar } from './EditorTabBar';
|
||||
import { EditorToolbar } from './EditorToolbar';
|
||||
import { QuickOpenDialog } from './QuickOpenDialog';
|
||||
import { SearchInFilesPanel } from './SearchInFilesPanel';
|
||||
|
||||
import type {
|
||||
EditorSelectionAction,
|
||||
EditorSelectionInfo,
|
||||
ReadFileResult,
|
||||
} from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ProjectEditorOverlayProps {
|
||||
projectPath: string;
|
||||
onClose: () => void;
|
||||
/** Called when user triggers an action from the selection menu */
|
||||
onEditorAction?: (action: EditorSelectionAction) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const ProjectEditorOverlay = ({
|
||||
projectPath,
|
||||
onClose,
|
||||
onEditorAction,
|
||||
}: ProjectEditorOverlayProps): React.ReactElement => {
|
||||
const openEditor = useStore((s) => s.openEditor);
|
||||
const closeEditor = useStore((s) => s.closeEditor);
|
||||
const openFile = useStore((s) => s.openFile);
|
||||
const closeTab = useStore((s) => s.closeTab);
|
||||
const saveFile = useStore((s) => s.saveFile);
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const openTabs = useStore((s) => s.editorOpenTabs);
|
||||
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
||||
const saveErrors = useStore((s) => s.editorSaveError);
|
||||
const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges);
|
||||
const saveAllFiles = useStore((s) => s.saveAllFiles);
|
||||
const discardChanges = useStore((s) => s.discardChanges);
|
||||
|
||||
// Iter-5: git, watcher, conflict
|
||||
const externalChanges = useStore((s) => s.editorExternalChanges);
|
||||
const clearExternalChange = useStore((s) => s.clearExternalChange);
|
||||
const conflictFile = useStore((s) => s.editorConflictFile);
|
||||
const forceOverwrite = useStore((s) => s.forceOverwrite);
|
||||
const resolveConflict = useStore((s) => s.resolveConflict);
|
||||
const setFileMtime = useStore((s) => s.setFileMtime);
|
||||
const fetchGitStatus = useStore((s) => s.fetchGitStatus);
|
||||
|
||||
const [fileContent, setFileContent] = useState<ReadFileResult | null>(null);
|
||||
const [fileLoading, setFileLoading] = useState(false);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [cursorLine, setCursorLine] = useState(1);
|
||||
const [cursorCol, setCursorCol] = useState(1);
|
||||
|
||||
// Unsaved changes confirmation (overlay close)
|
||||
const [showConfirmClose, setShowConfirmClose] = useState(false);
|
||||
// Unsaved changes confirmation (single tab close)
|
||||
const [confirmCloseTabId, setConfirmCloseTabId] = useState<string | null>(null);
|
||||
// Draft recovery banner
|
||||
const [draftRecoveredFile, setDraftRecoveredFile] = useState<string | null>(null);
|
||||
// Bumped on draft discard to force CodeMirrorEditor remount (fresh state cache)
|
||||
const [editorResetKey, setEditorResetKey] = useState(0);
|
||||
// Selection action menu
|
||||
const [selectionInfo, setSelectionInfo] = useState<EditorSelectionInfo | null>(null);
|
||||
const editorContentRef = useRef<HTMLDivElement>(null);
|
||||
const [containerRect, setContainerRect] = useState<DOMRect>(() => new DOMRect());
|
||||
|
||||
// Iter-4: New state
|
||||
const [quickOpenVisible, setQuickOpenVisible] = useState(false);
|
||||
const [searchPanelVisible, setSearchPanelVisible] = useState(false);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
const [sidebarVisible, setSidebarVisibleRaw] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('editor-sidebar-visible') !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// IPC deduplication: reuse in-flight readFile promise for same path
|
||||
const pendingReads = useRef(new Map<string, Promise<ReadFileResult>>());
|
||||
|
||||
// Active tab metadata
|
||||
const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null;
|
||||
|
||||
const loadFileContent = useCallback(
|
||||
async (filePath: string) => {
|
||||
setFileLoading(true);
|
||||
setFileError(null);
|
||||
setFileContent(null);
|
||||
|
||||
try {
|
||||
let promise = pendingReads.current.get(filePath);
|
||||
if (!promise) {
|
||||
promise = window.electronAPI.editor.readFile(filePath);
|
||||
pendingReads.current.set(filePath, promise);
|
||||
void promise.finally(() => pendingReads.current.delete(filePath));
|
||||
}
|
||||
const result = await promise;
|
||||
setFileContent(result);
|
||||
|
||||
// Track baseline mtime for conflict detection
|
||||
if (result.mtimeMs) {
|
||||
setFileMtime(filePath, result.mtimeMs);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setFileError(message);
|
||||
} finally {
|
||||
setFileLoading(false);
|
||||
}
|
||||
},
|
||||
[setFileMtime]
|
||||
);
|
||||
|
||||
// Active tab save error
|
||||
const activeSaveError = activeTabId ? (saveErrors[activeTabId] ?? null) : null;
|
||||
|
||||
// Initialize editor on mount
|
||||
useEffect(() => {
|
||||
void openEditor(projectPath);
|
||||
return () => {
|
||||
closeEditor();
|
||||
};
|
||||
}, [projectPath, openEditor, closeEditor]);
|
||||
|
||||
// Keep container rect fresh for selection menu positioning (resize, sidebar toggle)
|
||||
useEffect(() => {
|
||||
const el = editorContentRef.current;
|
||||
if (!el) return;
|
||||
const updateRect = (): void => setContainerRect(el.getBoundingClientRect());
|
||||
updateRect();
|
||||
const observer = new ResizeObserver(updateRect);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Escape to close + F5 to refresh (with dialog guard)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Don't close overlay if a dialog is open — dialog handles its own Escape
|
||||
if (quickOpenVisible || searchPanelVisible || shortcutsHelpVisible) return;
|
||||
if (showConfirmClose || confirmCloseTabId) return;
|
||||
if (conflictFile) return;
|
||||
|
||||
e.preventDefault();
|
||||
handleCloseRequest();
|
||||
}
|
||||
|
||||
// F5: Manual refresh (git status + file tree)
|
||||
if (e.key === 'F5') {
|
||||
e.preventDefault();
|
||||
handleManualRefresh();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleCloseRequest and handleManualRefresh are stable callbacks; listing dialog visibility guards as deps is sufficient
|
||||
}, [
|
||||
quickOpenVisible,
|
||||
searchPanelVisible,
|
||||
shortcutsHelpVisible,
|
||||
showConfirmClose,
|
||||
confirmCloseTabId,
|
||||
conflictFile,
|
||||
]);
|
||||
|
||||
// Focus trap — focus overlay on mount
|
||||
useEffect(() => {
|
||||
overlayRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Load file content when active tab changes
|
||||
useEffect(() => {
|
||||
// Clear selection menu from previous tab
|
||||
setSelectionInfo(null);
|
||||
|
||||
if (!activeTabId) {
|
||||
setFileContent(null);
|
||||
setFileLoading(false);
|
||||
setFileError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadFileContent(activeTabId);
|
||||
}, [activeTabId, loadFileContent]);
|
||||
|
||||
// Clear draft recovery banner when switching tabs
|
||||
useEffect(() => {
|
||||
if (activeTabId !== draftRecoveredFile) {
|
||||
setDraftRecoveredFile(null);
|
||||
}
|
||||
}, [activeTabId, draftRecoveredFile]);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(filePath: string) => {
|
||||
openFile(filePath);
|
||||
},
|
||||
[openFile]
|
||||
);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (activeTabId) {
|
||||
void loadFileContent(activeTabId);
|
||||
}
|
||||
}, [activeTabId, loadFileContent]);
|
||||
|
||||
const handleCursorChange = useCallback((line: number, col: number) => {
|
||||
setCursorLine(line);
|
||||
setCursorCol(col);
|
||||
}, []);
|
||||
|
||||
// --- Overlay close handlers ---
|
||||
|
||||
const handleCloseRequest = useCallback(() => {
|
||||
if (hasUnsavedChanges()) {
|
||||
setShowConfirmClose(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose, hasUnsavedChanges]);
|
||||
|
||||
const handleSaveAndClose = useCallback(async () => {
|
||||
await saveAllFiles();
|
||||
setShowConfirmClose(false);
|
||||
onClose();
|
||||
}, [saveAllFiles, onClose]);
|
||||
|
||||
const handleDiscardAndClose = useCallback(() => {
|
||||
setShowConfirmClose(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowConfirmClose(false);
|
||||
}, []);
|
||||
|
||||
// --- Tab close handlers (with dirty check) ---
|
||||
|
||||
const handleRequestCloseTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (modifiedFiles[tabId]) {
|
||||
setConfirmCloseTabId(tabId);
|
||||
} else {
|
||||
closeTab(tabId);
|
||||
}
|
||||
},
|
||||
[modifiedFiles, closeTab]
|
||||
);
|
||||
|
||||
// Listen for editor-close-tab custom events from keyboard shortcut hook
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const tabId = (e as CustomEvent).detail as string;
|
||||
handleRequestCloseTab(tabId);
|
||||
};
|
||||
window.addEventListener('editor-close-tab', handler);
|
||||
return () => window.removeEventListener('editor-close-tab', handler);
|
||||
}, [handleRequestCloseTab]);
|
||||
|
||||
const handleSaveAndCloseTab = useCallback(async () => {
|
||||
if (!confirmCloseTabId) return;
|
||||
await saveFile(confirmCloseTabId);
|
||||
closeTab(confirmCloseTabId);
|
||||
setConfirmCloseTabId(null);
|
||||
}, [confirmCloseTabId, saveFile, closeTab]);
|
||||
|
||||
const handleDiscardAndCloseTab = useCallback(() => {
|
||||
if (!confirmCloseTabId) return;
|
||||
closeTab(confirmCloseTabId);
|
||||
setConfirmCloseTabId(null);
|
||||
}, [confirmCloseTabId, closeTab]);
|
||||
|
||||
const handleCancelCloseTab = useCallback(() => {
|
||||
setConfirmCloseTabId(null);
|
||||
}, []);
|
||||
|
||||
// --- Draft recovery handlers ---
|
||||
|
||||
const handleDraftRecovered = useCallback((filePath: string) => {
|
||||
setDraftRecoveredFile(filePath);
|
||||
}, []);
|
||||
|
||||
const handleDiscardDraft = useCallback(() => {
|
||||
if (!draftRecoveredFile || !activeTabId) return;
|
||||
discardChanges(draftRecoveredFile);
|
||||
setDraftRecoveredFile(null);
|
||||
setFileContent(null);
|
||||
setEditorResetKey((k) => k + 1);
|
||||
void loadFileContent(activeTabId);
|
||||
}, [draftRecoveredFile, activeTabId, discardChanges, loadFileContent]);
|
||||
|
||||
const handleDismissDraftBanner = useCallback(() => {
|
||||
setDraftRecoveredFile(null);
|
||||
}, []);
|
||||
|
||||
// --- Iter-5: Conflict handlers ---
|
||||
|
||||
const handleForceOverwrite = useCallback(() => {
|
||||
if (!conflictFile) return;
|
||||
void forceOverwrite(conflictFile);
|
||||
}, [conflictFile, forceOverwrite]);
|
||||
|
||||
const handleCancelConflict = useCallback(() => {
|
||||
resolveConflict();
|
||||
}, [resolveConflict]);
|
||||
|
||||
// --- Iter-5: External change handlers ---
|
||||
|
||||
const handleReloadExternalChange = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
clearExternalChange(activeTabId);
|
||||
discardChanges(activeTabId);
|
||||
setFileContent(null);
|
||||
setEditorResetKey((k) => k + 1);
|
||||
void loadFileContent(activeTabId);
|
||||
}, [activeTabId, clearExternalChange, discardChanges, loadFileContent]);
|
||||
|
||||
const handleKeepMine = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
clearExternalChange(activeTabId);
|
||||
}, [activeTabId, clearExternalChange]);
|
||||
|
||||
// --- Iter-5: Watcher toggle ---
|
||||
|
||||
// --- Iter-5: Manual refresh (F5) ---
|
||||
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
void fetchGitStatus();
|
||||
}, [fetchGitStatus]);
|
||||
|
||||
// --- Iter-4: Toggle handlers ---
|
||||
|
||||
const toggleQuickOpen = useCallback(() => {
|
||||
setQuickOpenVisible((v) => !v);
|
||||
}, []);
|
||||
|
||||
const toggleSearchPanel = useCallback(() => {
|
||||
setSearchPanelVisible((v) => !v);
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarVisibleRaw((v) => {
|
||||
const next = !v;
|
||||
try {
|
||||
localStorage.setItem('editor-sidebar-visible', String(next));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// --- Iter-4: Search result selection ---
|
||||
|
||||
const handleSearchSelectMatch = useCallback(
|
||||
(filePath: string, _line: number) => {
|
||||
openFile(filePath);
|
||||
// Future enhancement: scroll to line in CM6 after file loads
|
||||
},
|
||||
[openFile]
|
||||
);
|
||||
|
||||
// --- Keyboard shortcuts ---
|
||||
|
||||
useEditorKeyboardShortcuts({
|
||||
onToggleQuickOpen: toggleQuickOpen,
|
||||
onToggleSearchPanel: toggleSearchPanel,
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onClose: handleCloseRequest,
|
||||
});
|
||||
|
||||
const projectName = projectPath.split('/').pop() ?? projectPath;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-surface"
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Project Editor"
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center justify-between border-b border-border px-3"
|
||||
style={{ paddingLeft: 'var(--macos-traffic-light-padding-left, 72px)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span className="font-medium text-text">{projectName}</span>
|
||||
<span className="text-text-muted">{projectPath}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Refresh (F5)"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Refresh git status (F5)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setShortcutsHelpVisible(true)}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Keyboard shortcuts</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
onClick={handleCloseRequest}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close editor"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Search in files panel (replaces sidebar when visible) */}
|
||||
{searchPanelVisible && (
|
||||
<div className="w-72 shrink-0">
|
||||
<SearchInFilesPanel
|
||||
projectPath={projectPath}
|
||||
onClose={() => setSearchPanelVisible(false)}
|
||||
onSelectMatch={handleSearchSelectMatch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File tree sidebar */}
|
||||
{sidebarVisible && !searchPanelVisible && (
|
||||
<div className="flex w-60 shrink-0 flex-col border-r border-border bg-surface-sidebar">
|
||||
<div className="flex items-center justify-between border-b border-border px-2 py-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-text-muted">
|
||||
Explorer
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Hide sidebar"
|
||||
>
|
||||
<PanelLeftClose className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<EditorFileTree selectedFilePath={activeTabId} onFileSelect={handleFileSelect} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar toggle (when hidden) */}
|
||||
{!sidebarVisible && !searchPanelVisible && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="flex h-full w-6 shrink-0 items-start justify-center border-r border-border bg-surface-sidebar pt-2 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Show sidebar"
|
||||
>
|
||||
<PanelLeftOpen className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Editor area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<EditorTabBar onRequestCloseTab={handleRequestCloseTab} />
|
||||
|
||||
{/* Toolbar */}
|
||||
<EditorToolbar />
|
||||
|
||||
{/* Draft recovery banner */}
|
||||
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300">
|
||||
<RotateCcw className="size-3.5 shrink-0" />
|
||||
<span>Recovered unsaved changes from a previous session.</span>
|
||||
<button
|
||||
onClick={handleDismissDraftBanner}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardDraft}
|
||||
className="rounded px-2 py-0.5 text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save error banner */}
|
||||
{activeSaveError && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs text-red-300">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span className="truncate">Save failed: {activeSaveError}</span>
|
||||
<button
|
||||
onClick={() => activeTabId && void saveFile(activeTabId)}
|
||||
className="ml-auto shrink-0 rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External change banner */}
|
||||
{activeTabId && externalChanges[activeTabId] && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-blue-500/30 bg-blue-500/10 px-3 py-1.5 text-xs text-blue-300">
|
||||
<RefreshCw className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{externalChanges[activeTabId] === 'delete'
|
||||
? 'File no longer exists on disk.'
|
||||
: 'File changed on disk.'}
|
||||
</span>
|
||||
{externalChanges[activeTabId] === 'delete' ? (
|
||||
<button
|
||||
onClick={() => closeTab(activeTabId)}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Close tab
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReloadExternalChange}
|
||||
className="ml-auto rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleKeepMine}
|
||||
className="rounded px-2 py-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Keep mine
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor content */}
|
||||
<div ref={editorContentRef} className="relative flex-1 overflow-hidden">
|
||||
{fileLoading && (
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileError && <EditorErrorState error={fileError} onRetry={handleRetry} />}
|
||||
|
||||
{fileContent?.isBinary && activeTabId && (
|
||||
<EditorBinaryState filePath={activeTabId} size={fileContent.size} />
|
||||
)}
|
||||
|
||||
{fileContent && !fileContent.isBinary && activeTabId && (
|
||||
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
|
||||
<CodeMirrorEditor
|
||||
key={`${activeTabId}-${editorResetKey}`}
|
||||
filePath={activeTabId}
|
||||
content={fileContent.content}
|
||||
fileName={activeTabId.split('/').pop() ?? 'file'}
|
||||
mtimeMs={fileContent.mtimeMs}
|
||||
onCursorChange={handleCursorChange}
|
||||
onDraftRecovered={handleDraftRecovered}
|
||||
onSelectionChange={setSelectionInfo}
|
||||
/>
|
||||
</EditorErrorBoundary>
|
||||
)}
|
||||
|
||||
{!fileLoading && !fileError && !fileContent && !activeTabId && <EditorEmptyState />}
|
||||
|
||||
{/* Selection action menu */}
|
||||
{selectionInfo && onEditorAction && (
|
||||
<EditorSelectionMenu
|
||||
info={selectionInfo}
|
||||
containerRect={containerRect}
|
||||
onSendMessage={() => {
|
||||
onEditorAction(buildSelectionAction('sendMessage', selectionInfo));
|
||||
setSelectionInfo(null);
|
||||
}}
|
||||
onCreateTask={() => {
|
||||
onEditorAction(buildSelectionAction('createTask', selectionInfo));
|
||||
setSelectionInfo(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{activeTab && (
|
||||
<EditorStatusBar line={cursorLine} col={cursorCol} language={activeTab.language} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Open dialog */}
|
||||
{quickOpenVisible && (
|
||||
<QuickOpenDialog
|
||||
onClose={() => setQuickOpenVisible(false)}
|
||||
onSelectFile={handleFileSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shortcuts help modal */}
|
||||
{shortcutsHelpVisible && (
|
||||
<EditorShortcutsHelp onClose={() => setShortcutsHelpVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Unsaved changes confirmation dialog — overlay close */}
|
||||
{showConfirmClose && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
You have unsaved changes. What would you like to do?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelClose}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardAndClose}
|
||||
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard & Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveAndClose()}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
Save All & Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save conflict dialog */}
|
||||
{conflictFile && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Save Conflict</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
The file has been modified externally since you opened it. Overwrite with your
|
||||
changes?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelConflict}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleForceOverwrite}
|
||||
className="rounded bg-orange-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-orange-500"
|
||||
>
|
||||
Overwrite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unsaved changes confirmation dialog — single tab close */}
|
||||
{confirmCloseTabId && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="w-96 rounded-lg border border-border bg-surface p-6 shadow-xl">
|
||||
<h3 className="mb-2 text-sm font-semibold text-text">Unsaved Changes</h3>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
This file has unsaved changes. What would you like to do?
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancelCloseTab}
|
||||
className="rounded px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDiscardAndCloseTab}
|
||||
className="rounded px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-400/10"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSaveAndCloseTab()}
|
||||
className="rounded bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-500"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
147
src/renderer/components/team/editor/QuickOpenDialog.tsx
Normal file
147
src/renderer/components/team/editor/QuickOpenDialog.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Quick Open dialog (Cmd+P) — fuzzy file search using cmdk.
|
||||
*
|
||||
* Escape closes dialog (not the editor overlay).
|
||||
* Flatten file tree on mount, filter with cmdk built-in fuzzy matching.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Command } from 'cmdk';
|
||||
|
||||
import { getFileIcon } from './fileIcons';
|
||||
|
||||
import type { FileTreeEntry } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface QuickOpenDialogProps {
|
||||
onClose: () => void;
|
||||
onSelectFile: (filePath: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const QuickOpenDialog = ({
|
||||
onClose,
|
||||
onSelectFile,
|
||||
}: QuickOpenDialogProps): React.ReactElement => {
|
||||
const fileTree = useStore((s) => s.editorFileTree);
|
||||
const projectPath = useStore((s) => s.editorProjectPath);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Flatten file tree into searchable list
|
||||
const flatFiles = useMemo(() => {
|
||||
if (!fileTree) return [];
|
||||
const files: { path: string; name: string; relativePath: string }[] = [];
|
||||
flattenTree(fileTree, files, projectPath ?? '');
|
||||
return files;
|
||||
}, [fileTree, projectPath]);
|
||||
|
||||
// Escape to close dialog (not overlay)
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
onSelectFile(value);
|
||||
onClose();
|
||||
},
|
||||
[onSelectFile, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
|
||||
>
|
||||
<Command label="Quick Open" shouldFilter={true}>
|
||||
<Command.Input
|
||||
placeholder="Search files by name..."
|
||||
className="w-full border-b border-border bg-transparent px-4 py-3 text-sm text-text outline-none placeholder:text-text-muted"
|
||||
autoFocus
|
||||
/>
|
||||
<Command.List className="max-h-80 overflow-y-auto p-1">
|
||||
<Command.Empty className="p-6 text-center text-sm text-text-muted">
|
||||
No files found
|
||||
</Command.Empty>
|
||||
{flatFiles.map((file) => {
|
||||
const iconInfo = getFileIcon(file.name);
|
||||
const Icon = iconInfo.icon;
|
||||
return (
|
||||
<Command.Item
|
||||
key={file.path}
|
||||
value={file.relativePath}
|
||||
onSelect={() => handleSelect(file.path)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-sm text-text-secondary aria-selected:bg-surface-raised aria-selected:text-text"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" style={{ color: iconInfo.color }} />
|
||||
<span className="truncate font-medium">{file.name}</span>
|
||||
<span className="ml-auto truncate text-xs text-text-muted">
|
||||
{file.relativePath}
|
||||
</span>
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function flattenTree(
|
||||
entries: FileTreeEntry[],
|
||||
result: { path: string; name: string; relativePath: string }[],
|
||||
projectRoot: string
|
||||
): void {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'file' && !entry.isSensitive) {
|
||||
const relativePath = entry.path.startsWith(projectRoot)
|
||||
? entry.path.slice(projectRoot.length + 1)
|
||||
: entry.name;
|
||||
result.push({
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
if (entry.children) {
|
||||
flattenTree(entry.children, result, projectRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
354
src/renderer/components/team/editor/SearchInFilesPanel.tsx
Normal file
354
src/renderer/components/team/editor/SearchInFilesPanel.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
/**
|
||||
* Search in files panel (Cmd+Shift+F).
|
||||
*
|
||||
* Debounced literal string search with cancellation.
|
||||
* Results are clickable to open the file at the matched line.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { Loader2, Search, X } from 'lucide-react';
|
||||
|
||||
import { getFileIcon } from './fileIcons';
|
||||
|
||||
import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface SearchInFilesPanelProps {
|
||||
projectPath: string;
|
||||
onClose: () => void;
|
||||
onSelectMatch: (filePath: string, line: number) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const SearchInFilesPanel = ({
|
||||
projectPath,
|
||||
onClose,
|
||||
onSelectMatch,
|
||||
}: SearchInFilesPanelProps): React.ReactElement => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [results, setResults] = useState<SearchInFilesResult | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
// Monotonic request ID — prevents stale results from overwriting fresh ones
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Escape closes panel (capture phase to prevent overlay close)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
const doSearch = useCallback(async (searchQuery: string, isCaseSensitive: boolean) => {
|
||||
if (!searchQuery.trim()) {
|
||||
setResults(null);
|
||||
setSearching(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bump request ID — any in-flight request with a lower ID is stale
|
||||
const myRequestId = ++requestIdRef.current;
|
||||
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await api.editor.searchInFiles({
|
||||
query: searchQuery,
|
||||
caseSensitive: isCaseSensitive,
|
||||
});
|
||||
|
||||
// Discard result if a newer request was fired while we were waiting
|
||||
if (requestIdRef.current !== myRequestId) return;
|
||||
|
||||
setResults(result);
|
||||
|
||||
// Auto-expand first few files
|
||||
const firstFiles = new Set(result.results.slice(0, 5).map((r) => r.filePath));
|
||||
setExpandedFiles(firstFiles);
|
||||
} catch (err) {
|
||||
if (requestIdRef.current !== myRequestId) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
if (requestIdRef.current === myRequestId) {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleQueryChange = useCallback(
|
||||
(value: string) => {
|
||||
setQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void doSearch(value, caseSensitive);
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[caseSensitive, doSearch]
|
||||
);
|
||||
|
||||
const handleCaseSensitiveToggle = useCallback(() => {
|
||||
const newValue = !caseSensitive;
|
||||
setCaseSensitive(newValue);
|
||||
if (query.trim()) {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
void doSearch(query, newValue);
|
||||
}
|
||||
}, [caseSensitive, query, doSearch]);
|
||||
|
||||
const toggleFileExpanded = useCallback((filePath: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getRelativePath = useCallback(
|
||||
(filePath: string) => {
|
||||
return filePath.startsWith(projectPath) ? filePath.slice(projectPath.length + 1) : filePath;
|
||||
},
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r border-border bg-surface-sidebar">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-xs font-medium text-text-secondary">Search in Files</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="flex items-center gap-1 rounded border border-border bg-surface px-2 py-1">
|
||||
<Search className="size-3.5 shrink-0 text-text-muted" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-xs text-text outline-none placeholder:text-text-muted"
|
||||
/>
|
||||
{searching && <Loader2 className="size-3 shrink-0 animate-spin text-text-muted" />}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleCaseSensitiveToggle}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
caseSensitive
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-text-muted hover:bg-surface-raised'
|
||||
}`}
|
||||
aria-label="Match Case"
|
||||
aria-pressed={caseSensitive}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Match Case</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && <div className="p-3 text-xs text-red-400">{error}</div>}
|
||||
|
||||
{results?.totalMatches === 0 && query.trim() && (
|
||||
<div className="p-4 text-center text-xs text-text-muted">No results found</div>
|
||||
)}
|
||||
|
||||
{results && results.totalMatches > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border px-3 py-1.5 text-[10px] text-text-muted">
|
||||
{results.totalMatches} match{results.totalMatches !== 1 ? 'es' : ''} in{' '}
|
||||
{results.results.length} file{results.results.length !== 1 ? 's' : ''}
|
||||
{results.truncated && ' (truncated)'}
|
||||
</div>
|
||||
{results.results.map((fileResult) => (
|
||||
<SearchFileGroup
|
||||
key={fileResult.filePath}
|
||||
fileResult={fileResult}
|
||||
relativePath={getRelativePath(fileResult.filePath)}
|
||||
expanded={expandedFiles.has(fileResult.filePath)}
|
||||
onToggle={() => toggleFileExpanded(fileResult.filePath)}
|
||||
onSelectMatch={(line) => onSelectMatch(fileResult.filePath, line)}
|
||||
query={query}
|
||||
caseSensitive={caseSensitive}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// File group
|
||||
// =============================================================================
|
||||
|
||||
interface SearchFileGroupProps {
|
||||
fileResult: SearchFileResult;
|
||||
relativePath: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onSelectMatch: (line: number) => void;
|
||||
query: string;
|
||||
caseSensitive: boolean;
|
||||
}
|
||||
|
||||
const SearchFileGroup = ({
|
||||
fileResult,
|
||||
relativePath,
|
||||
expanded,
|
||||
onToggle,
|
||||
onSelectMatch,
|
||||
query,
|
||||
caseSensitive,
|
||||
}: SearchFileGroupProps): React.ReactElement => {
|
||||
const fileName = relativePath.split('/').pop() ?? relativePath;
|
||||
const dirPath = relativePath.includes('/')
|
||||
? relativePath.slice(0, relativePath.lastIndexOf('/'))
|
||||
: '';
|
||||
const iconInfo = getFileIcon(fileName);
|
||||
const Icon = iconInfo.icon;
|
||||
|
||||
return (
|
||||
<div className="border-border/50 border-b">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
<span className="text-[10px] text-text-muted">{expanded ? '▼' : '▶'}</span>
|
||||
<Icon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
|
||||
<span className="truncate text-xs font-medium text-text">{fileName}</span>
|
||||
{dirPath && <span className="ml-1 truncate text-[10px] text-text-muted">{dirPath}</span>}
|
||||
<span className="ml-auto shrink-0 text-[10px] text-text-muted">
|
||||
{fileResult.matches.length}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="pb-1">
|
||||
{fileResult.matches.map((match, idx) => (
|
||||
<button
|
||||
key={`${match.line}-${idx}`}
|
||||
onClick={() => onSelectMatch(match.line)}
|
||||
className="flex w-full items-center gap-2 px-6 py-0.5 text-left transition-colors hover:bg-surface-raised"
|
||||
>
|
||||
<span className="w-8 shrink-0 text-right text-[10px] text-text-muted">
|
||||
{match.line}
|
||||
</span>
|
||||
<HighlightedLine
|
||||
text={match.lineContent}
|
||||
query={query}
|
||||
caseSensitive={caseSensitive}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Highlighted line
|
||||
// =============================================================================
|
||||
|
||||
interface HighlightedLineProps {
|
||||
text: string;
|
||||
query: string;
|
||||
caseSensitive: boolean;
|
||||
}
|
||||
|
||||
const HighlightedLine = ({
|
||||
text,
|
||||
query,
|
||||
caseSensitive,
|
||||
}: HighlightedLineProps): React.ReactElement => {
|
||||
if (!query) {
|
||||
return <span className="truncate text-[11px] text-text-secondary">{text}</span>;
|
||||
}
|
||||
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
const searchQuery = caseSensitive ? query : query.toLowerCase();
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
let idx = searchText.indexOf(searchQuery);
|
||||
while (idx !== -1) {
|
||||
if (idx > lastIndex) {
|
||||
parts.push(
|
||||
<span key={`t-${lastIndex}`} className="text-text-secondary">
|
||||
{text.slice(lastIndex, idx)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
<span key={`h-${idx}`} className="rounded bg-yellow-500/30 text-yellow-200">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</span>
|
||||
);
|
||||
lastIndex = idx + query.length;
|
||||
idx = searchText.indexOf(searchQuery, lastIndex);
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(
|
||||
<span key={`t-${lastIndex}`} className="text-text-secondary">
|
||||
{text.slice(lastIndex)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="truncate text-[11px]">{parts}</span>;
|
||||
};
|
||||
173
src/renderer/components/team/editor/fileIcons.ts
Normal file
173
src/renderer/components/team/editor/fileIcons.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* File icon mapping — maps file extensions to lucide-react icon names and colors.
|
||||
*/
|
||||
|
||||
import {
|
||||
Braces,
|
||||
Code,
|
||||
Database,
|
||||
File,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileText,
|
||||
FileType,
|
||||
Image,
|
||||
Lock,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface FileIconInfo {
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Extension → Icon mapping
|
||||
// =============================================================================
|
||||
|
||||
const EXTENSION_MAP: Record<string, FileIconInfo> = {
|
||||
// TypeScript / JavaScript
|
||||
ts: { icon: FileCode, color: '#3178c6' },
|
||||
tsx: { icon: FileCode, color: '#3178c6' },
|
||||
js: { icon: FileCode, color: '#f7df1e' },
|
||||
jsx: { icon: FileCode, color: '#61dafb' },
|
||||
mjs: { icon: FileCode, color: '#f7df1e' },
|
||||
cjs: { icon: FileCode, color: '#f7df1e' },
|
||||
|
||||
// Web
|
||||
html: { icon: Code, color: '#e34c26' },
|
||||
htm: { icon: Code, color: '#e34c26' },
|
||||
css: { icon: FileCode, color: '#563d7c' },
|
||||
scss: { icon: FileCode, color: '#c6538c' },
|
||||
less: { icon: FileCode, color: '#1d365d' },
|
||||
vue: { icon: FileCode, color: '#42b883' },
|
||||
svelte: { icon: FileCode, color: '#ff3e00' },
|
||||
|
||||
// Data / Config
|
||||
json: { icon: FileJson, color: '#cbcb41' },
|
||||
jsonl: { icon: FileJson, color: '#cbcb41' },
|
||||
yaml: { icon: Settings, color: '#cb171e' },
|
||||
yml: { icon: Settings, color: '#cb171e' },
|
||||
toml: { icon: Settings, color: '#9c4121' },
|
||||
xml: { icon: Code, color: '#e37933' },
|
||||
csv: { icon: Database, color: '#4caf50' },
|
||||
|
||||
// Markdown / Text
|
||||
md: { icon: FileText, color: '#519aba' },
|
||||
mdx: { icon: FileText, color: '#519aba' },
|
||||
txt: { icon: FileText, color: '#89949f' },
|
||||
rst: { icon: FileText, color: '#89949f' },
|
||||
|
||||
// Python
|
||||
py: { icon: FileCode, color: '#3572a5' },
|
||||
pyx: { icon: FileCode, color: '#3572a5' },
|
||||
pyi: { icon: FileCode, color: '#3572a5' },
|
||||
|
||||
// Rust
|
||||
rs: { icon: FileCode, color: '#dea584' },
|
||||
|
||||
// Go
|
||||
go: { icon: FileCode, color: '#00add8' },
|
||||
|
||||
// Ruby
|
||||
rb: { icon: FileCode, color: '#cc342d' },
|
||||
gemspec: { icon: FileCode, color: '#cc342d' },
|
||||
|
||||
// Java / Kotlin
|
||||
java: { icon: FileCode, color: '#b07219' },
|
||||
kt: { icon: FileCode, color: '#a97bff' },
|
||||
kts: { icon: FileCode, color: '#a97bff' },
|
||||
|
||||
// C / C++
|
||||
c: { icon: FileCode, color: '#555555' },
|
||||
h: { icon: FileCode, color: '#555555' },
|
||||
cpp: { icon: FileCode, color: '#f34b7d' },
|
||||
hpp: { icon: FileCode, color: '#f34b7d' },
|
||||
cc: { icon: FileCode, color: '#f34b7d' },
|
||||
|
||||
// Shell
|
||||
sh: { icon: Terminal, color: '#89e051' },
|
||||
bash: { icon: Terminal, color: '#89e051' },
|
||||
zsh: { icon: Terminal, color: '#89e051' },
|
||||
fish: { icon: Terminal, color: '#89e051' },
|
||||
|
||||
// SQL
|
||||
sql: { icon: Database, color: '#e38c00' },
|
||||
|
||||
// Images
|
||||
png: { icon: Image, color: '#a074c4' },
|
||||
jpg: { icon: Image, color: '#a074c4' },
|
||||
jpeg: { icon: Image, color: '#a074c4' },
|
||||
gif: { icon: Image, color: '#a074c4' },
|
||||
svg: { icon: Image, color: '#ffb13b' },
|
||||
ico: { icon: Image, color: '#a074c4' },
|
||||
webp: { icon: Image, color: '#a074c4' },
|
||||
|
||||
// Fonts
|
||||
woff: { icon: FileType, color: '#89949f' },
|
||||
woff2: { icon: FileType, color: '#89949f' },
|
||||
ttf: { icon: FileType, color: '#89949f' },
|
||||
otf: { icon: FileType, color: '#89949f' },
|
||||
|
||||
// Config files
|
||||
env: { icon: Lock, color: '#e5a00d' },
|
||||
ini: { icon: Settings, color: '#89949f' },
|
||||
conf: { icon: Settings, color: '#89949f' },
|
||||
cfg: { icon: Settings, color: '#89949f' },
|
||||
|
||||
// Other
|
||||
graphql: { icon: Braces, color: '#e535ab' },
|
||||
gql: { icon: Braces, color: '#e535ab' },
|
||||
proto: { icon: Code, color: '#89949f' },
|
||||
dart: { icon: FileCode, color: '#00b4ab' },
|
||||
swift: { icon: FileCode, color: '#f05138' },
|
||||
php: { icon: FileCode, color: '#4f5d95' },
|
||||
};
|
||||
|
||||
// Special full filename mapping
|
||||
const FILENAME_MAP: Record<string, FileIconInfo> = {
|
||||
Dockerfile: { icon: FileCode, color: '#2496ed' },
|
||||
'docker-compose.yml': { icon: FileCode, color: '#2496ed' },
|
||||
'docker-compose.yaml': { icon: FileCode, color: '#2496ed' },
|
||||
Makefile: { icon: Terminal, color: '#427819' },
|
||||
Rakefile: { icon: Terminal, color: '#cc342d' },
|
||||
Gemfile: { icon: FileCode, color: '#cc342d' },
|
||||
'.gitignore': { icon: Settings, color: '#f05032' },
|
||||
'.gitattributes': { icon: Settings, color: '#f05032' },
|
||||
'.eslintrc': { icon: Settings, color: '#4b32c3' },
|
||||
'.prettierrc': { icon: Settings, color: '#56b3b4' },
|
||||
'tsconfig.json': { icon: Settings, color: '#3178c6' },
|
||||
'package.json': { icon: FileJson, color: '#cb3837' },
|
||||
'pnpm-lock.yaml': { icon: Lock, color: '#f69220' },
|
||||
'package-lock.json': { icon: Lock, color: '#cb3837' },
|
||||
'yarn.lock': { icon: Lock, color: '#2c8ebb' },
|
||||
LICENSE: { icon: FileText, color: '#d9b611' },
|
||||
'CLAUDE.md': { icon: FileText, color: '#d97706' },
|
||||
};
|
||||
|
||||
const DEFAULT_ICON: FileIconInfo = { icon: File, color: '#89949f' };
|
||||
|
||||
// =============================================================================
|
||||
// Public API
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get icon info for a file by name.
|
||||
*/
|
||||
export function getFileIcon(fileName: string): FileIconInfo {
|
||||
// Check full filename first
|
||||
if (FILENAME_MAP[fileName]) return FILENAME_MAP[fileName];
|
||||
|
||||
// Check extension
|
||||
const ext = fileName.includes('.') ? fileName.split('.').pop()?.toLowerCase() : undefined;
|
||||
if (ext && EXTENSION_MAP[ext]) return EXTENSION_MAP[ext];
|
||||
|
||||
return DEFAULT_ICON;
|
||||
}
|
||||
|
|
@ -1,28 +1,16 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { cpp } from '@codemirror/lang-cpp';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { go } from '@codemirror/lang-go';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { java } from '@codemirror/lang-java';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { less } from '@codemirror/lang-less';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { php } from '@codemirror/lang-php';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { rust } from '@codemirror/lang-rust';
|
||||
import { sass } from '@codemirror/lang-sass';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { indentUnit, LanguageDescription, syntaxHighlighting } from '@codemirror/language';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import { indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
|
||||
import {
|
||||
getAsyncLanguageDesc,
|
||||
getSyncLanguageExtension,
|
||||
} from '@renderer/utils/codemirrorLanguages';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
|
||||
import {
|
||||
acceptChunk,
|
||||
|
|
@ -60,73 +48,6 @@ interface CodeMirrorDiffViewProps {
|
|||
portionSize?: number;
|
||||
}
|
||||
|
||||
/** Synchronous language extension for common file types (bundled by Vite) */
|
||||
function getSyncLanguageExtension(fileName: string): Extension | null {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return javascript({
|
||||
jsx: ext === 'tsx' || ext === 'jsx',
|
||||
typescript: ext === 'ts' || ext === 'tsx',
|
||||
});
|
||||
case 'py':
|
||||
return python();
|
||||
case 'json':
|
||||
case 'jsonl':
|
||||
return json();
|
||||
case 'css':
|
||||
return css();
|
||||
case 'scss':
|
||||
return sass({ indented: false });
|
||||
case 'sass':
|
||||
return sass({ indented: true });
|
||||
case 'less':
|
||||
return less();
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return html();
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
return xml();
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
case 'markdown':
|
||||
return markdown();
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return yaml();
|
||||
case 'rs':
|
||||
return rust();
|
||||
case 'go':
|
||||
return go();
|
||||
case 'java':
|
||||
return java();
|
||||
case 'c':
|
||||
case 'h':
|
||||
case 'cpp':
|
||||
case 'cxx':
|
||||
case 'cc':
|
||||
case 'hpp':
|
||||
return cpp();
|
||||
case 'php':
|
||||
return php();
|
||||
case 'sql':
|
||||
return sql();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Async fallback: match by filename via @codemirror/language-data for rare languages */
|
||||
function getAsyncLanguageDesc(fileName: string): LanguageDescription | null {
|
||||
return LanguageDescription.matchFilename(languages, fileName);
|
||||
}
|
||||
|
||||
/** Compute hunk index for the chunk at a given position (B-side / modified doc).
|
||||
* If the position falls inside a chunk, returns that chunk's index.
|
||||
* Otherwise returns the nearest chunk by distance (avoids defaulting to 0). */
|
||||
|
|
@ -154,49 +75,8 @@ function computeHunkIndexAtPos(state: EditorState, pos: number): number {
|
|||
return nearestIndex;
|
||||
}
|
||||
|
||||
/** Custom dark theme for diff view using CSS variables */
|
||||
const diffTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: '13px',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderRight: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '11px',
|
||||
minWidth: 'auto',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 4px 0 8px',
|
||||
minWidth: '2ch',
|
||||
textAlign: 'right',
|
||||
opacity: '0.5',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--color-text)',
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: 'var(--color-text)',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
||||
},
|
||||
// Diff-specific line/block backgrounds
|
||||
/** Diff-specific theme — merge toolbar, changed/deleted line backgrounds, collapse markers */
|
||||
const diffSpecificTheme = EditorView.theme({
|
||||
'.cm-changedLine': { backgroundColor: '#1a3a1a !important' },
|
||||
'.cm-deletedChunk': { backgroundColor: '#241517', position: 'relative', overflow: 'visible' },
|
||||
'.cm-insertedLine': { backgroundColor: '#1a3a1a !important' },
|
||||
|
|
@ -476,7 +356,8 @@ export const CodeMirrorDiffView = ({
|
|||
|
||||
const buildExtensions = useCallback(() => {
|
||||
const extensions: Extension[] = [
|
||||
diffTheme,
|
||||
baseEditorTheme,
|
||||
diffSpecificTheme,
|
||||
lineNumbers(),
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
EditorView.editable.of(!readOnly),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getFileHunkCount } from '@renderer/store/slices/changeReviewSlice';
|
||||
import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
X as XIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { TreeNode } from '@renderer/utils/fileTreeBuilder';
|
||||
import type { HunkDecision } from '@shared/types';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
|
|
@ -29,59 +31,8 @@ interface ReviewFileTreeProps {
|
|||
activeFilePath?: string;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
isFile: boolean;
|
||||
file?: FileChangeSummary;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
type FileStatus = 'pending' | 'accepted' | 'rejected' | 'mixed';
|
||||
|
||||
function buildTree(files: FileChangeSummary[]): TreeNode[] {
|
||||
const root: TreeNode = { name: '', fullPath: '', isFile: false, children: [] };
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.relativePath.split('/');
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
const fullPath = parts.slice(0, i + 1).join('/');
|
||||
|
||||
let child = current.children.find((c) => c.name === part);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: part,
|
||||
fullPath,
|
||||
isFile: isLast,
|
||||
file: isLast ? file : undefined,
|
||||
children: [],
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
|
||||
function collapse(node: TreeNode): TreeNode {
|
||||
const collapsed: TreeNode = { ...node, children: node.children.map(collapse) };
|
||||
if (!collapsed.isFile && collapsed.children.length === 1 && !collapsed.children[0].isFile) {
|
||||
const child = collapsed.children[0];
|
||||
return {
|
||||
...child,
|
||||
name: `${collapsed.name}/${child.name}`,
|
||||
children: child.children,
|
||||
};
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
return collapse(root).children;
|
||||
}
|
||||
|
||||
function getFileStatus(
|
||||
file: FileChangeSummary,
|
||||
hunkDecisions: Record<string, HunkDecision>,
|
||||
|
|
@ -157,7 +108,7 @@ const TreeItem = ({
|
|||
collapsedFolders,
|
||||
onToggleFolder,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
node: TreeNode<FileChangeSummary>;
|
||||
selectedFilePath: string | null;
|
||||
activeFilePath?: string;
|
||||
onSelectFile: (filePath: string) => void;
|
||||
|
|
@ -169,14 +120,14 @@ const TreeItem = ({
|
|||
collapsedFolders: Set<string>;
|
||||
onToggleFolder: (fullPath: string) => void;
|
||||
}): JSX.Element => {
|
||||
if (node.isFile && node.file) {
|
||||
const isSelected = node.file.filePath === selectedFilePath;
|
||||
const isActive = node.file.filePath === activeFilePath && !isSelected;
|
||||
const status = getFileStatus(node.file, hunkDecisions, fileDecisions, fileChunkCounts);
|
||||
if (node.isFile && node.data) {
|
||||
const isSelected = node.data.filePath === selectedFilePath;
|
||||
const isActive = node.data.filePath === activeFilePath && !isSelected;
|
||||
const status = getFileStatus(node.data, hunkDecisions, fileDecisions, fileChunkCounts);
|
||||
return (
|
||||
<button
|
||||
data-tree-file={node.file.filePath}
|
||||
onClick={() => onSelectFile(node.file!.filePath)}
|
||||
data-tree-file={node.data.filePath}
|
||||
onClick={() => onSelectFile(node.data!.filePath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
|
|
@ -189,7 +140,7 @@ const TreeItem = ({
|
|||
>
|
||||
<FileStatusIcon status={status} />
|
||||
<File className="size-3.5 shrink-0" />
|
||||
{viewedSet && viewedSet.has(node.file.filePath) && (
|
||||
{viewedSet && viewedSet.has(node.data.filePath) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0">
|
||||
|
|
@ -208,11 +159,11 @@ const TreeItem = ({
|
|||
{node.name}
|
||||
</span>
|
||||
<span className="ml-1 flex shrink-0 items-center gap-1">
|
||||
{node.file.linesAdded > 0 && (
|
||||
<span className="text-green-400">+{node.file.linesAdded}</span>
|
||||
{node.data.linesAdded > 0 && (
|
||||
<span className="text-green-400">+{node.data.linesAdded}</span>
|
||||
)}
|
||||
{node.file.linesRemoved > 0 && (
|
||||
<span className="text-red-400">-{node.file.linesRemoved}</span>
|
||||
{node.data.linesRemoved > 0 && (
|
||||
<span className="text-red-400">-{node.data.linesRemoved}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -239,27 +190,22 @@ const TreeItem = ({
|
|||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
{isOpen &&
|
||||
[...node.children]
|
||||
.sort((a, b) => {
|
||||
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((child) => (
|
||||
<TreeItem
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
selectedFilePath={selectedFilePath}
|
||||
activeFilePath={activeFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
depth={depth + 1}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
fileChunkCounts={fileChunkCounts}
|
||||
viewedSet={viewedSet}
|
||||
collapsedFolders={collapsedFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
/>
|
||||
))}
|
||||
sortTreeNodes(node.children).map((child) => (
|
||||
<TreeItem
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
selectedFilePath={selectedFilePath}
|
||||
activeFilePath={activeFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
depth={depth + 1}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
fileChunkCounts={fileChunkCounts}
|
||||
viewedSet={viewedSet}
|
||||
collapsedFolders={collapsedFolders}
|
||||
onToggleFolder={onToggleFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -274,12 +220,12 @@ function applyExpandAncestors(prev: Set<string>, ancestors: string[]): Set<strin
|
|||
return next;
|
||||
}
|
||||
|
||||
function getAncestorFolderPaths(tree: TreeNode[], filePath: string): string[] {
|
||||
function getAncestorFolderPaths(tree: TreeNode<FileChangeSummary>[], filePath: string): string[] {
|
||||
const paths: string[] = [];
|
||||
|
||||
function walk(nodes: TreeNode[], ancestors: string[]): boolean {
|
||||
function walk(nodes: TreeNode<FileChangeSummary>[], ancestors: string[]): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.isFile && node.file?.filePath === filePath) {
|
||||
if (node.isFile && node.data?.filePath === filePath) {
|
||||
paths.push(...ancestors);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -304,7 +250,7 @@ export const ReviewFileTree = ({
|
|||
const hunkDecisions = useStore((state) => state.hunkDecisions);
|
||||
const fileDecisions = useStore((state) => state.fileDecisions);
|
||||
const fileChunkCounts = useStore((state) => state.fileChunkCounts);
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
const tree = useMemo(() => buildTree(files, (f) => f.relativePath), [files]);
|
||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const toggleFolder = useCallback((fullPath: string) => {
|
||||
|
|
@ -350,27 +296,22 @@ export const ReviewFileTree = ({
|
|||
|
||||
return (
|
||||
<div className="py-1">
|
||||
{[...tree]
|
||||
.sort((a, b) => {
|
||||
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((node) => (
|
||||
<TreeItem
|
||||
key={node.fullPath}
|
||||
node={node}
|
||||
selectedFilePath={selectedFilePath}
|
||||
activeFilePath={activeFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
depth={0}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
fileChunkCounts={fileChunkCounts}
|
||||
viewedSet={viewedSet}
|
||||
collapsedFolders={collapsedFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
/>
|
||||
))}
|
||||
{sortTreeNodes(tree).map((node) => (
|
||||
<TreeItem
|
||||
key={node.fullPath}
|
||||
node={node}
|
||||
selectedFilePath={selectedFilePath}
|
||||
activeFilePath={activeFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
depth={0}
|
||||
hunkDecisions={hunkDecisions}
|
||||
fileDecisions={fileDecisions}
|
||||
fileChunkCounts={fileChunkCounts}
|
||||
viewedSet={viewedSet}
|
||||
collapsedFolders={collapsedFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
218
src/renderer/hooks/useEditorKeyboardShortcuts.ts
Normal file
218
src/renderer/hooks/useEditorKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
/**
|
||||
* useEditorKeyboardShortcuts — keyboard shortcuts scoped to the project editor overlay.
|
||||
*
|
||||
* All shortcuts use stopPropagation to prevent conflicts with global useKeyboardShortcuts.
|
||||
* CM6-internal shortcuts (Cmd+Z, Cmd+Shift+Z, Cmd+A, Cmd+D) are handled by CodeMirror directly.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { gotoLine, openSearchPanel } from '@codemirror/search';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
|
||||
import type { EditorFileTab } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface UseEditorKeyboardShortcutsOptions {
|
||||
onToggleQuickOpen: () => void;
|
||||
onToggleSearchPanel: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Dependencies injected into the key handler for testability. */
|
||||
export interface EditorKeyHandlerDeps {
|
||||
activeTabId: string | null;
|
||||
openTabs: EditorFileTab[];
|
||||
setActiveTab: (id: string) => void;
|
||||
saveFile: (tabId: string) => Promise<void>;
|
||||
saveAllFiles: () => Promise<void>;
|
||||
hasUnsavedChanges: () => boolean;
|
||||
onToggleQuickOpen: () => void;
|
||||
onToggleSearchPanel: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
getEditorView: () => { dispatch: unknown } | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pure key handler (exported for testing)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a keyboard event handler for editor shortcuts.
|
||||
* Extracted from the hook for unit-testability.
|
||||
*/
|
||||
export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: KeyboardEvent) => void {
|
||||
return (e: KeyboardEvent) => {
|
||||
const isMod = e.metaKey || e.ctrlKey;
|
||||
if (!isMod) return;
|
||||
|
||||
// Cmd+P: Quick Open
|
||||
if (e.key === 'p' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deps.onToggleQuickOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+F: Search in files
|
||||
if (e.key === 'f' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deps.onToggleSearchPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+F: Find in current file (CM6)
|
||||
if (e.key === 'f' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const view = deps.getEditorView();
|
||||
if (view) openSearchPanel(view as Parameters<typeof openSearchPanel>[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+G: Go to line
|
||||
if (e.key === 'g' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const view = deps.getEditorView();
|
||||
if (view) gotoLine(view as Parameters<typeof gotoLine>[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+S: Save current file
|
||||
if (e.key === 's' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (deps.activeTabId) void deps.saveFile(deps.activeTabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+S: Save all files
|
||||
if (e.key === 's' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (deps.hasUnsavedChanges()) void deps.saveAllFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+W: Close current editor tab
|
||||
if (e.key === 'w' && !e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (deps.activeTabId) {
|
||||
// Let overlay handle dirty check via onRequestCloseTab
|
||||
const closeEvent = new CustomEvent('editor-close-tab', { detail: deps.activeTabId });
|
||||
window.dispatchEvent(closeEvent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+B: Toggle sidebar
|
||||
if (e.key === 'b') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deps.onToggleSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+]: Next tab
|
||||
if (e.key === ']' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
||||
if (idx !== -1 && idx < deps.openTabs.length - 1) {
|
||||
deps.setActiveTab(deps.openTabs[idx + 1].id);
|
||||
} else if (deps.openTabs.length > 0) {
|
||||
deps.setActiveTab(deps.openTabs[0].id); // wrap
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+[: Previous tab
|
||||
if (e.key === '[' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
||||
if (idx > 0) {
|
||||
deps.setActiveTab(deps.openTabs[idx - 1].id);
|
||||
} else if (deps.openTabs.length > 0) {
|
||||
deps.setActiveTab(deps.openTabs[deps.openTabs.length - 1].id); // wrap
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab: Tab cycling
|
||||
if (e.ctrlKey && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
||||
if (e.shiftKey) {
|
||||
const prev = idx > 0 ? idx - 1 : deps.openTabs.length - 1;
|
||||
if (deps.openTabs[prev]) deps.setActiveTab(deps.openTabs[prev].id);
|
||||
} else {
|
||||
const next = idx < deps.openTabs.length - 1 ? idx + 1 : 0;
|
||||
if (deps.openTabs[next]) deps.setActiveTab(deps.openTabs[next].id);
|
||||
}
|
||||
}
|
||||
|
||||
// Escape: Close editor (handled separately in overlay with dialog guards)
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
||||
export function useEditorKeyboardShortcuts({
|
||||
onToggleQuickOpen,
|
||||
onToggleSearchPanel,
|
||||
onToggleSidebar,
|
||||
onClose: _onClose,
|
||||
}: UseEditorKeyboardShortcutsOptions): void {
|
||||
const openTabs = useStore((s) => s.editorOpenTabs);
|
||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||
const setActiveTab = useStore((s) => s.setActiveTab);
|
||||
const saveFile = useStore((s) => s.saveFile);
|
||||
const saveAllFiles = useStore((s) => s.saveAllFiles);
|
||||
const hasUnsavedChanges = useStore((s) => s.hasUnsavedChanges);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const handler = createEditorKeyHandler({
|
||||
activeTabId,
|
||||
openTabs,
|
||||
setActiveTab,
|
||||
saveFile,
|
||||
saveAllFiles,
|
||||
hasUnsavedChanges,
|
||||
onToggleQuickOpen,
|
||||
onToggleSearchPanel,
|
||||
onToggleSidebar,
|
||||
getEditorView: () => editorBridge.getView(),
|
||||
});
|
||||
handler(e);
|
||||
},
|
||||
[
|
||||
activeTabId,
|
||||
openTabs,
|
||||
setActiveTab,
|
||||
saveFile,
|
||||
saveAllFiles,
|
||||
hasUnsavedChanges,
|
||||
onToggleQuickOpen,
|
||||
onToggleSearchPanel,
|
||||
onToggleSidebar,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown, true); // capture phase
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ export function useKeyboardShortcuts(): void {
|
|||
activeContextId,
|
||||
switchContext,
|
||||
isContextSwitching,
|
||||
editorOpen,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
openTabs: s.openTabs,
|
||||
|
|
@ -69,6 +70,7 @@ export function useKeyboardShortcuts(): void {
|
|||
activeContextId: s.activeContextId,
|
||||
switchContext: s.switchContext,
|
||||
isContextSwitching: s.isContextSwitching,
|
||||
editorOpen: s.editorProjectPath !== null,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -77,6 +79,24 @@ export function useKeyboardShortcuts(): void {
|
|||
// Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
|
||||
// Editor scope guard: when the editor overlay is open, these shortcuts are
|
||||
// handled by useEditorKeyboardShortcuts — yield control to avoid conflicts.
|
||||
if (editorOpen) {
|
||||
const isConflicting =
|
||||
// Ctrl+Tab — editor tab cycling
|
||||
(event.ctrlKey && event.key === 'Tab') ||
|
||||
// Cmd+W — editor close tab
|
||||
(isMod && event.key === 'w' && !event.altKey && !event.shiftKey) ||
|
||||
// Cmd+B — editor sidebar toggle
|
||||
(isMod && event.key === 'b') ||
|
||||
// Cmd+F — editor find in file (CM6)
|
||||
(isMod && event.key === 'f') ||
|
||||
// Cmd+Shift+[ / ] — editor tab switching
|
||||
(isMod && event.shiftKey && (event.key === '[' || event.key === ']'));
|
||||
|
||||
if (isConflicting) return;
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab: Switch tabs within focused pane (universal shortcut)
|
||||
if (event.ctrlKey && event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
|
|
@ -303,5 +323,6 @@ export function useKeyboardShortcuts(): void {
|
|||
activeContextId,
|
||||
switchContext,
|
||||
isContextSwitching,
|
||||
editorOpen,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { createConfigSlice } from './slices/configSlice';
|
|||
import { createConnectionSlice } from './slices/connectionSlice';
|
||||
import { createContextSlice } from './slices/contextSlice';
|
||||
import { createConversationSlice } from './slices/conversationSlice';
|
||||
import { createEditorSlice } from './slices/editorSlice';
|
||||
import { createNotificationSlice } from './slices/notificationSlice';
|
||||
import { createPaneSlice } from './slices/paneSlice';
|
||||
import { createProjectSlice } from './slices/projectSlice';
|
||||
|
|
@ -52,6 +53,7 @@ export const useStore = create<AppState>()((...args) => ({
|
|||
...createUpdateSlice(...args),
|
||||
...createChangeReviewSlice(...args),
|
||||
...createCliInstallerSlice(...args),
|
||||
...createEditorSlice(...args),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -363,6 +365,19 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
}
|
||||
|
||||
// Listen for editor file change events (chokidar watcher → renderer)
|
||||
if (api.editor?.onEditorChange) {
|
||||
const cleanup = api.editor.onEditorChange((event) => {
|
||||
const state = useStore.getState();
|
||||
if (state.editorProjectPath) {
|
||||
state.handleExternalFileChange(event);
|
||||
}
|
||||
});
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanupFns.push(cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check CLI status on startup
|
||||
if (api.cliInstaller) {
|
||||
void useStore.getState().fetchCliStatus();
|
||||
|
|
|
|||
928
src/renderer/store/slices/editorSlice.ts
Normal file
928
src/renderer/store/slices/editorSlice.ts
Normal file
|
|
@ -0,0 +1,928 @@
|
|||
/**
|
||||
* Editor slice — manages project editor state.
|
||||
*
|
||||
* Group 1: File tree state + actions (iter-1)
|
||||
* Group 2: Tab management (iter-2)
|
||||
* Group 3: Dirty/save state (iter-2)
|
||||
* Group 4: File operations (iter-3)
|
||||
*/
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { getLanguageFromFileName } from '@renderer/utils/codemirrorLanguages';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type {
|
||||
EditorFileChangeEvent,
|
||||
EditorFileTab,
|
||||
FileTreeEntry,
|
||||
GitFileStatus,
|
||||
} from '@shared/types/editor';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const log = createLogger('Store:editor');
|
||||
|
||||
/** Remove a key from a record without triggering unused-variable linting. */
|
||||
function omitKey<V>(record: Record<string, V>, key: string): Record<string, V> {
|
||||
const result = { ...record };
|
||||
delete result[key];
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cooldown map: filePath → timestamp of last successful save.
|
||||
*
|
||||
* Used to suppress watcher events that arrive after editorSaving is cleared
|
||||
* (race condition: atomic write → IPC response → clear saving flag → watcher fires).
|
||||
* macOS FSEvents can delay up to ~1s; 2s cooldown covers all platforms safely.
|
||||
*
|
||||
* Module-level (not in store state) to avoid unnecessary re-renders.
|
||||
*/
|
||||
const recentSaveTimestamps = new Map<string, number>();
|
||||
const SAVE_COOLDOWN_MS = 2000;
|
||||
|
||||
/**
|
||||
* Cooldown map: filePath → timestamp of last successful move.
|
||||
* Suppresses watcher events triggered by our own move operations.
|
||||
*/
|
||||
const recentMoveTimestamps = new Map<string, number>();
|
||||
const MOVE_COOLDOWN_MS = 2000;
|
||||
|
||||
// =============================================================================
|
||||
// Slice Interface
|
||||
// =============================================================================
|
||||
|
||||
export interface EditorSlice {
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 1: File tree state + actions
|
||||
// ═══════════════════════════════════════════════════════
|
||||
editorProjectPath: string | null;
|
||||
editorFileTree: FileTreeEntry[] | null;
|
||||
editorFileTreeLoading: boolean;
|
||||
editorFileTreeError: string | null;
|
||||
editorExpandedDirs: Record<string, boolean>;
|
||||
|
||||
openEditor: (projectPath: string) => Promise<void>;
|
||||
closeEditor: () => void;
|
||||
loadFileTree: (dirPath: string) => Promise<void>;
|
||||
expandDirectory: (dirPath: string) => Promise<void>;
|
||||
collapseDirectory: (dirPath: string) => void;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 2: Tab management
|
||||
// ═══════════════════════════════════════════════════════
|
||||
editorOpenTabs: EditorFileTab[];
|
||||
editorActiveTabId: string | null;
|
||||
|
||||
openFile: (filePath: string) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
setActiveTab: (tabId: string) => void;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 3: Content + Save
|
||||
// Content lives in EditorState (Map<tabId, EditorState> in useRef).
|
||||
// Store only tracks dirty flags, loading, and save status.
|
||||
// ═══════════════════════════════════════════════════════
|
||||
editorFileLoading: Record<string, boolean>;
|
||||
editorModifiedFiles: Record<string, boolean>;
|
||||
editorSaving: Record<string, boolean>;
|
||||
editorSaveError: Record<string, string>;
|
||||
|
||||
markFileModified: (filePath: string) => void;
|
||||
markFileSaved: (filePath: string) => void;
|
||||
saveFile: (filePath: string) => Promise<void>;
|
||||
saveAllFiles: () => Promise<void>;
|
||||
discardChanges: (filePath: string) => void;
|
||||
hasUnsavedChanges: () => boolean;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 4: File operations (iter-3)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
editorCreating: boolean;
|
||||
editorCreateError: string | null;
|
||||
|
||||
createFileInTree: (parentDir: string, fileName: string) => Promise<string | null>;
|
||||
createDirInTree: (parentDir: string, dirName: string) => Promise<string | null>;
|
||||
deleteFileFromTree: (filePath: string) => Promise<boolean>;
|
||||
moveFileInTree: (sourcePath: string, destDir: string) => Promise<boolean>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 5: Git status + file watcher + line wrap (iter-5)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
editorGitFiles: GitFileStatus[];
|
||||
editorGitBranch: string | null;
|
||||
editorIsGitRepo: boolean;
|
||||
editorGitLoading: boolean;
|
||||
editorWatcherEnabled: boolean;
|
||||
editorLineWrap: boolean;
|
||||
/** Files changed on disk while open (absolute paths) */
|
||||
editorExternalChanges: Record<string, EditorFileChangeEvent['type']>;
|
||||
/** Baseline mtime per file (for conflict detection) */
|
||||
editorFileMtimes: Record<string, number>;
|
||||
/** File path with active save conflict (null = no conflict) */
|
||||
editorConflictFile: string | null;
|
||||
|
||||
fetchGitStatus: () => Promise<void>;
|
||||
toggleWatcher: (enable: boolean) => Promise<void>;
|
||||
toggleLineWrap: () => void;
|
||||
handleExternalFileChange: (event: EditorFileChangeEvent) => void;
|
||||
clearExternalChange: (filePath: string) => void;
|
||||
setFileMtime: (filePath: string, mtimeMs: number) => void;
|
||||
forceOverwrite: (filePath: string) => Promise<void>;
|
||||
resolveConflict: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Slice Creator
|
||||
// =============================================================================
|
||||
|
||||
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set, get) => ({
|
||||
// Group 1 initial state
|
||||
editorProjectPath: null,
|
||||
editorFileTree: null,
|
||||
editorFileTreeLoading: false,
|
||||
editorFileTreeError: null,
|
||||
editorExpandedDirs: {},
|
||||
|
||||
// Group 2 initial state
|
||||
editorOpenTabs: [],
|
||||
editorActiveTabId: null,
|
||||
|
||||
// Group 3 initial state
|
||||
editorFileLoading: {},
|
||||
editorModifiedFiles: {},
|
||||
editorSaving: {},
|
||||
editorSaveError: {},
|
||||
|
||||
// Group 4 initial state
|
||||
editorCreating: false,
|
||||
editorCreateError: null,
|
||||
|
||||
// Group 5 initial state
|
||||
editorGitFiles: [],
|
||||
editorGitBranch: null,
|
||||
editorIsGitRepo: false,
|
||||
editorGitLoading: false,
|
||||
editorWatcherEnabled: false,
|
||||
editorLineWrap: (() => {
|
||||
try {
|
||||
return localStorage.getItem('editor-line-wrap') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
editorExternalChanges: {},
|
||||
editorFileMtimes: {},
|
||||
editorConflictFile: null,
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 1: File tree actions
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
openEditor: async (projectPath: string) => {
|
||||
set({
|
||||
editorProjectPath: projectPath,
|
||||
editorFileTree: null,
|
||||
editorFileTreeLoading: true,
|
||||
editorFileTreeError: null,
|
||||
editorExpandedDirs: {},
|
||||
editorOpenTabs: [],
|
||||
editorActiveTabId: null,
|
||||
editorFileLoading: {},
|
||||
editorModifiedFiles: {},
|
||||
editorSaving: {},
|
||||
editorSaveError: {},
|
||||
editorCreating: false,
|
||||
editorCreateError: null,
|
||||
editorGitFiles: [],
|
||||
editorGitBranch: null,
|
||||
editorIsGitRepo: false,
|
||||
editorGitLoading: false,
|
||||
editorWatcherEnabled: false,
|
||||
editorExternalChanges: {},
|
||||
editorFileMtimes: {},
|
||||
editorConflictFile: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await api.editor.open(projectPath);
|
||||
const result = await api.editor.readDir(projectPath);
|
||||
set({
|
||||
editorFileTree: result.entries,
|
||||
editorFileTreeLoading: false,
|
||||
});
|
||||
|
||||
// Fetch git status in background (non-blocking)
|
||||
void get().fetchGitStatus();
|
||||
|
||||
// Auto-enable file watcher (standard editor behavior)
|
||||
void get().toggleWatcher(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('Failed to open editor:', message);
|
||||
set({
|
||||
editorFileTreeLoading: false,
|
||||
editorFileTreeError: message,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
closeEditor: () => {
|
||||
// Clear cooldown timestamps (no stale entries across editor sessions)
|
||||
recentSaveTimestamps.clear();
|
||||
recentMoveTimestamps.clear();
|
||||
|
||||
// Best-effort IPC cleanup
|
||||
api.editor.close().catch((e: unknown) => {
|
||||
log.error('editor:close failed:', e);
|
||||
});
|
||||
|
||||
// Cleanup bridge (destroys EditorView, clears caches)
|
||||
editorBridge.destroy();
|
||||
|
||||
set({
|
||||
editorProjectPath: null,
|
||||
editorFileTree: null,
|
||||
editorFileTreeLoading: false,
|
||||
editorFileTreeError: null,
|
||||
editorExpandedDirs: {},
|
||||
editorOpenTabs: [],
|
||||
editorActiveTabId: null,
|
||||
editorFileLoading: {},
|
||||
editorModifiedFiles: {},
|
||||
editorSaving: {},
|
||||
editorSaveError: {},
|
||||
editorCreating: false,
|
||||
editorCreateError: null,
|
||||
editorGitFiles: [],
|
||||
editorGitBranch: null,
|
||||
editorIsGitRepo: false,
|
||||
editorGitLoading: false,
|
||||
editorWatcherEnabled: false,
|
||||
editorExternalChanges: {},
|
||||
editorFileMtimes: {},
|
||||
editorConflictFile: null,
|
||||
});
|
||||
},
|
||||
|
||||
loadFileTree: async (dirPath: string) => {
|
||||
set({ editorFileTreeLoading: true, editorFileTreeError: null });
|
||||
|
||||
try {
|
||||
const result = await api.editor.readDir(dirPath);
|
||||
set({
|
||||
editorFileTree: result.entries,
|
||||
editorFileTreeLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('Failed to load file tree:', message);
|
||||
set({
|
||||
editorFileTreeLoading: false,
|
||||
editorFileTreeError: message,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
expandDirectory: async (dirPath: string) => {
|
||||
const { editorExpandedDirs, editorFileTree } = get();
|
||||
|
||||
// Mark as expanded immediately for responsive UI
|
||||
set({
|
||||
editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await api.editor.readDir(dirPath);
|
||||
const updatedTree = mergeChildrenIntoTree(editorFileTree ?? [], dirPath, result.entries);
|
||||
set({ editorFileTree: updatedTree });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('Failed to expand directory:', message);
|
||||
const current = get().editorExpandedDirs;
|
||||
set({ editorExpandedDirs: omitKey(current, dirPath) });
|
||||
}
|
||||
},
|
||||
|
||||
collapseDirectory: (dirPath: string) => {
|
||||
const { editorExpandedDirs } = get();
|
||||
set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) });
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 2: Tab management
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
openFile: (filePath: string) => {
|
||||
const { editorOpenTabs } = get();
|
||||
|
||||
// Dedup: if file already open, just activate it
|
||||
const existing = editorOpenTabs.find((t) => t.filePath === filePath);
|
||||
if (existing) {
|
||||
set({ editorActiveTabId: existing.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = filePath.split('/').pop() ?? 'file';
|
||||
const language = getLanguageFromFileName(fileName);
|
||||
|
||||
const tab: EditorFileTab = {
|
||||
id: filePath,
|
||||
filePath,
|
||||
fileName,
|
||||
language,
|
||||
};
|
||||
|
||||
const newTabs = computeDisambiguatedTabs([...editorOpenTabs, tab]);
|
||||
|
||||
set({
|
||||
editorOpenTabs: newTabs,
|
||||
editorActiveTabId: tab.id,
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: (tabId: string) => {
|
||||
const { editorOpenTabs, editorActiveTabId, editorModifiedFiles, editorSaveError } = get();
|
||||
const filtered = editorOpenTabs.filter((t) => t.id !== tabId);
|
||||
|
||||
// Clean up dirty/error state for closed tab
|
||||
const restModified = omitKey(editorModifiedFiles, tabId);
|
||||
const restErrors = omitKey(editorSaveError, tabId);
|
||||
|
||||
// Clear cached EditorState from bridge
|
||||
editorBridge.deleteState(tabId);
|
||||
|
||||
// Clear draft from localStorage
|
||||
try {
|
||||
localStorage.removeItem(`editor-draft:${tabId}`);
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
|
||||
let newActiveId = editorActiveTabId;
|
||||
if (editorActiveTabId === tabId) {
|
||||
// Activate adjacent tab
|
||||
const closedIndex = editorOpenTabs.findIndex((t) => t.id === tabId);
|
||||
if (filtered.length > 0) {
|
||||
newActiveId = filtered[Math.min(closedIndex, filtered.length - 1)].id;
|
||||
} else {
|
||||
newActiveId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute disambiguation after removing tab
|
||||
const disambiguated = computeDisambiguatedTabs(filtered);
|
||||
|
||||
set({
|
||||
editorOpenTabs: disambiguated,
|
||||
editorActiveTabId: newActiveId,
|
||||
editorModifiedFiles: restModified,
|
||||
editorSaveError: restErrors,
|
||||
});
|
||||
},
|
||||
|
||||
setActiveTab: (tabId: string) => {
|
||||
set({ editorActiveTabId: tabId });
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 3: Content + Save
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
markFileModified: (filePath: string) => {
|
||||
const { editorModifiedFiles } = get();
|
||||
if (editorModifiedFiles[filePath]) return; // Already marked
|
||||
set({ editorModifiedFiles: { ...editorModifiedFiles, [filePath]: true } });
|
||||
},
|
||||
|
||||
markFileSaved: (filePath: string) => {
|
||||
const { editorModifiedFiles } = get();
|
||||
set({ editorModifiedFiles: omitKey(editorModifiedFiles, filePath) });
|
||||
},
|
||||
|
||||
saveFile: async (filePath: string) => {
|
||||
const content = editorBridge.getContent(filePath);
|
||||
if (content === null) {
|
||||
log.error('saveFile: no content available for', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
set((s) => ({
|
||||
editorSaving: { ...s.editorSaving, [filePath]: true },
|
||||
editorSaveError: omitKey(s.editorSaveError, filePath),
|
||||
}));
|
||||
|
||||
try {
|
||||
// Pass baseline mtime for conflict detection (if available)
|
||||
const baselineMtime = get().editorFileMtimes[filePath];
|
||||
const result = await api.editor.writeFile(filePath, content, baselineMtime);
|
||||
|
||||
// Record save timestamp BEFORE clearing editorSaving (watcher race guard)
|
||||
recentSaveTimestamps.set(filePath, Date.now());
|
||||
|
||||
// Update baseline mtime with the new value after successful save
|
||||
set((s) => ({
|
||||
editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath),
|
||||
editorSaving: omitKey(s.editorSaving, filePath),
|
||||
editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs },
|
||||
editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
|
||||
}));
|
||||
|
||||
try {
|
||||
localStorage.removeItem(`editor-draft:${filePath}`);
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Handle conflict errors specifically
|
||||
if (message.startsWith('CONFLICT')) {
|
||||
log.error('Save conflict detected:', filePath);
|
||||
set((s) => ({
|
||||
editorSaving: omitKey(s.editorSaving, filePath),
|
||||
editorConflictFile: filePath,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
log.error('Failed to save file:', message);
|
||||
set((s) => ({
|
||||
editorSaving: omitKey(s.editorSaving, filePath),
|
||||
editorSaveError: { ...s.editorSaveError, [filePath]: message },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
saveAllFiles: async () => {
|
||||
const { editorModifiedFiles } = get();
|
||||
const modifiedContent = editorBridge.getAllModifiedContent(editorModifiedFiles);
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [filePath, content] of modifiedContent) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
set((s) => ({
|
||||
editorSaving: { ...s.editorSaving, [filePath]: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
const baselineMtime = get().editorFileMtimes[filePath];
|
||||
const result = await api.editor.writeFile(filePath, content, baselineMtime);
|
||||
|
||||
// Record save timestamp BEFORE clearing editorSaving (watcher race guard)
|
||||
recentSaveTimestamps.set(filePath, Date.now());
|
||||
|
||||
set((s) => ({
|
||||
editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath),
|
||||
editorSaving: omitKey(s.editorSaving, filePath),
|
||||
editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs },
|
||||
editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
|
||||
}));
|
||||
try {
|
||||
localStorage.removeItem(`editor-draft:${filePath}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (message.startsWith('CONFLICT')) {
|
||||
log.error('Save conflict detected:', filePath);
|
||||
set((s) => ({
|
||||
editorSaving: omitKey(s.editorSaving, filePath),
|
||||
editorConflictFile: filePath,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
log.error('Failed to save file:', filePath, message);
|
||||
set((s) => ({
|
||||
editorSaving: omitKey(s.editorSaving, filePath),
|
||||
editorSaveError: { ...s.editorSaveError, [filePath]: message },
|
||||
}));
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
},
|
||||
|
||||
discardChanges: (filePath: string) => {
|
||||
const { editorModifiedFiles, editorSaveError } = get();
|
||||
set({
|
||||
editorModifiedFiles: omitKey(editorModifiedFiles, filePath),
|
||||
editorSaveError: omitKey(editorSaveError, filePath),
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.removeItem(`editor-draft:${filePath}`);
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
},
|
||||
|
||||
hasUnsavedChanges: () => {
|
||||
return Object.keys(get().editorModifiedFiles).length > 0;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 4: File operations
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
createFileInTree: async (parentDir: string, fileName: string) => {
|
||||
set({ editorCreating: true, editorCreateError: null });
|
||||
|
||||
try {
|
||||
const result = await api.editor.createFile(parentDir, fileName);
|
||||
|
||||
// Refresh parent directory in the tree
|
||||
await refreshDirectory(get, set, parentDir);
|
||||
|
||||
set({ editorCreating: false });
|
||||
return result.filePath;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('Failed to create file:', message);
|
||||
set({ editorCreating: false, editorCreateError: message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
createDirInTree: async (parentDir: string, dirName: string) => {
|
||||
set({ editorCreating: true, editorCreateError: null });
|
||||
|
||||
try {
|
||||
const result = await api.editor.createDir(parentDir, dirName);
|
||||
|
||||
// Refresh parent directory in the tree
|
||||
await refreshDirectory(get, set, parentDir);
|
||||
|
||||
set({ editorCreating: false });
|
||||
return result.dirPath;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('Failed to create directory:', message);
|
||||
set({ editorCreating: false, editorCreateError: message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteFileFromTree: async (filePath: string) => {
|
||||
try {
|
||||
await api.editor.deleteFile(filePath);
|
||||
|
||||
// Close tab if the deleted file is open
|
||||
const { editorOpenTabs } = get();
|
||||
const tabsToClose = editorOpenTabs.filter(
|
||||
(t) => t.filePath === filePath || t.filePath.startsWith(filePath + '/')
|
||||
);
|
||||
for (const tab of tabsToClose) {
|
||||
get().closeTab(tab.id);
|
||||
}
|
||||
|
||||
// Refresh parent directory
|
||||
const parentDir = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
if (parentDir) {
|
||||
await refreshDirectory(get, set, parentDir);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('Failed to delete file:', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
moveFileInTree: async (sourcePath: string, destDir: string) => {
|
||||
const { editorSaving } = get();
|
||||
|
||||
// Guard: don't move during save
|
||||
if (editorSaving[sourcePath]) {
|
||||
log.error('moveFileInTree: blocked — file is being saved:', sourcePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.editor.moveFile(sourcePath, destDir);
|
||||
const newPath = result.newPath;
|
||||
const oldParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
||||
|
||||
// Record move timestamps for watcher cooldown
|
||||
recentMoveTimestamps.set(sourcePath, Date.now());
|
||||
recentMoveTimestamps.set(newPath, Date.now());
|
||||
|
||||
// Check if source was a directory (for prefix-based remapping)
|
||||
const isDir = !sourcePath.includes('.') || sourcePath.endsWith('/');
|
||||
|
||||
// Atomic remap of all path-keyed state
|
||||
set((s) => {
|
||||
const tabs = s.editorOpenTabs.map((tab) => {
|
||||
const remapped = remapPath(tab.filePath, sourcePath, newPath);
|
||||
if (remapped === tab.filePath) return tab;
|
||||
const fileName = remapped.split('/').pop() ?? 'file';
|
||||
return {
|
||||
...tab,
|
||||
id: remapped,
|
||||
filePath: remapped,
|
||||
fileName,
|
||||
language: getLanguageFromFileName(fileName),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
editorOpenTabs: computeDisambiguatedTabs(tabs),
|
||||
editorActiveTabId:
|
||||
remapPath(s.editorActiveTabId ?? '', sourcePath, newPath) || s.editorActiveTabId,
|
||||
editorModifiedFiles: remapRecord(s.editorModifiedFiles, sourcePath, newPath),
|
||||
editorSaving: remapRecord(s.editorSaving, sourcePath, newPath),
|
||||
editorSaveError: remapRecord(s.editorSaveError, sourcePath, newPath),
|
||||
editorFileLoading: remapRecord(s.editorFileLoading, sourcePath, newPath),
|
||||
editorExternalChanges: remapRecord(s.editorExternalChanges, sourcePath, newPath),
|
||||
editorFileMtimes: remapRecord(s.editorFileMtimes, sourcePath, newPath),
|
||||
editorExpandedDirs: remapRecord(s.editorExpandedDirs, sourcePath, newPath),
|
||||
};
|
||||
});
|
||||
|
||||
// Remap bridge state for each affected tab
|
||||
const { editorOpenTabs } = get();
|
||||
for (const tab of editorOpenTabs) {
|
||||
// Check if this tab was affected by the move
|
||||
const originalPath = reverseRemapPath(tab.filePath, sourcePath, newPath);
|
||||
if (originalPath !== tab.filePath) {
|
||||
editorBridge.remapState(originalPath, tab.filePath);
|
||||
}
|
||||
}
|
||||
// Also remap for single file case
|
||||
if (!isDir) {
|
||||
editorBridge.remapState(sourcePath, newPath);
|
||||
}
|
||||
|
||||
// Remap localStorage drafts
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith('editor-draft:')) {
|
||||
const draftPath = key.slice('editor-draft:'.length);
|
||||
const remapped = remapPath(draftPath, sourcePath, newPath);
|
||||
if (remapped !== draftPath) {
|
||||
const value = localStorage.getItem(key);
|
||||
localStorage.removeItem(key);
|
||||
if (value !== null) localStorage.setItem(`editor-draft:${remapped}`, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
|
||||
// Remap recentSaveTimestamps
|
||||
for (const [key, ts] of [...recentSaveTimestamps.entries()]) {
|
||||
const remapped = remapPath(key, sourcePath, newPath);
|
||||
if (remapped !== key) {
|
||||
recentSaveTimestamps.delete(key);
|
||||
recentSaveTimestamps.set(remapped, ts);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh directories and git status in background
|
||||
void refreshDirectory(get, set, oldParent);
|
||||
void refreshDirectory(get, set, destDir);
|
||||
void get().fetchGitStatus();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('moveFileInTree failed:', message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 5: Git status + file watcher + line wrap
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
fetchGitStatus: async () => {
|
||||
set({ editorGitLoading: true });
|
||||
try {
|
||||
const result = await api.editor.gitStatus();
|
||||
set({
|
||||
editorGitFiles: result.files,
|
||||
editorGitBranch: result.branch,
|
||||
editorIsGitRepo: result.isGitRepo,
|
||||
editorGitLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Failed to fetch git status:', error);
|
||||
set({ editorGitLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
toggleWatcher: async (enable: boolean) => {
|
||||
try {
|
||||
await api.editor.watchDir(enable);
|
||||
set({ editorWatcherEnabled: enable });
|
||||
} catch (error) {
|
||||
log.error('Failed to toggle watcher:', error);
|
||||
}
|
||||
},
|
||||
|
||||
toggleLineWrap: () => {
|
||||
set((s) => {
|
||||
const next = !s.editorLineWrap;
|
||||
try {
|
||||
localStorage.setItem('editor-line-wrap', String(next));
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
return { editorLineWrap: next };
|
||||
});
|
||||
},
|
||||
|
||||
handleExternalFileChange: (event: EditorFileChangeEvent) => {
|
||||
const { editorOpenTabs, editorProjectPath, editorSaving } = get();
|
||||
|
||||
// Ignore watcher events for files we are currently saving (our own write)
|
||||
if (editorSaving[event.path]) return;
|
||||
|
||||
// Ignore watcher events within cooldown after save
|
||||
// (covers race: save completes → editorSaving cleared → watcher fires late)
|
||||
const lastSaveTime = recentSaveTimestamps.get(event.path);
|
||||
if (lastSaveTime && Date.now() - lastSaveTime < SAVE_COOLDOWN_MS) return;
|
||||
|
||||
// Ignore watcher events within cooldown after move
|
||||
const lastMoveTime = recentMoveTimestamps.get(event.path);
|
||||
if (lastMoveTime && Date.now() - lastMoveTime < MOVE_COOLDOWN_MS) return;
|
||||
|
||||
// Track changes for open files
|
||||
const isOpenFile = editorOpenTabs.some((t) => t.filePath === event.path);
|
||||
if (isOpenFile || event.type === 'delete') {
|
||||
set((s) => ({
|
||||
editorExternalChanges: {
|
||||
...s.editorExternalChanges,
|
||||
[event.path]: event.type,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Refresh git status on any change
|
||||
void get().fetchGitStatus();
|
||||
|
||||
// Refresh parent directory in tree for create/delete
|
||||
if (event.type === 'create' || event.type === 'delete') {
|
||||
const parentDir = event.path.substring(0, event.path.lastIndexOf('/'));
|
||||
if (parentDir && editorProjectPath) {
|
||||
void refreshDirectory(get, set, parentDir);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearExternalChange: (filePath: string) => {
|
||||
set((s) => ({
|
||||
editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
|
||||
}));
|
||||
},
|
||||
|
||||
setFileMtime: (filePath: string, mtimeMs: number) => {
|
||||
set((s) => ({
|
||||
editorFileMtimes: { ...s.editorFileMtimes, [filePath]: mtimeMs },
|
||||
}));
|
||||
},
|
||||
|
||||
forceOverwrite: async (filePath: string) => {
|
||||
const content = editorBridge.getContent(filePath);
|
||||
if (content === null) {
|
||||
log.error('forceOverwrite: no content available for', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
set((s) => ({
|
||||
editorSaving: { ...s.editorSaving, [filePath]: true },
|
||||
editorConflictFile: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
// No baselineMtimeMs → skip conflict check on backend
|
||||
const result = await api.editor.writeFile(filePath, content);
|
||||
|
||||
// Record save timestamp BEFORE clearing editorSaving (watcher race guard)
|
||||
recentSaveTimestamps.set(filePath, Date.now());
|
||||
|
||||
set((s) => ({
|
||||
editorModifiedFiles: omitKey(s.editorModifiedFiles, filePath),
|
||||
editorSaving: omitKey(s.editorSaving, filePath),
|
||||
editorFileMtimes: { ...s.editorFileMtimes, [filePath]: result.mtimeMs },
|
||||
editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
|
||||
}));
|
||||
|
||||
try {
|
||||
localStorage.removeItem(`editor-draft:${filePath}`);
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error('Failed to force overwrite:', message);
|
||||
set((s) => ({
|
||||
editorSaving: omitKey(s.editorSaving, filePath),
|
||||
editorSaveError: { ...s.editorSaveError, [filePath]: message },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
resolveConflict: () => {
|
||||
set({ editorConflictFile: null });
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Refresh a directory's children in the file tree via IPC readDir + merge.
|
||||
*/
|
||||
async function refreshDirectory(
|
||||
get: () => AppState,
|
||||
set: (partial: Partial<AppState>) => void,
|
||||
dirPath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await api.editor.readDir(dirPath);
|
||||
const currentTree = get().editorFileTree;
|
||||
if (currentTree) {
|
||||
const updatedTree = mergeChildrenIntoTree(currentTree, dirPath, result.entries);
|
||||
set({ editorFileTree: updatedTree });
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to refresh directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap a single path: if it matches oldPath exactly or is a child of oldPath,
|
||||
* replace the prefix with newPath.
|
||||
*/
|
||||
function remapPath(p: string, oldPath: string, newPath: string): string {
|
||||
if (p === oldPath) return newPath;
|
||||
if (p.startsWith(oldPath + '/')) {
|
||||
return newPath + p.slice(oldPath.length);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse remap: given a potentially-remapped path, recover the original path.
|
||||
* Used to identify which bridge caches to remap.
|
||||
*/
|
||||
function reverseRemapPath(p: string, oldPath: string, newPath: string): string {
|
||||
if (p === newPath) return oldPath;
|
||||
if (p.startsWith(newPath + '/')) {
|
||||
return oldPath + p.slice(newPath.length);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap all keys in a Record that match or are children of oldPath.
|
||||
*/
|
||||
function remapRecord<V>(
|
||||
record: Record<string, V>,
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Record<string, V> {
|
||||
const result: Record<string, V> = {};
|
||||
let changed = false;
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const remapped = remapPath(key, oldPath, newPath);
|
||||
if (remapped !== key) changed = true;
|
||||
result[remapped] = value;
|
||||
}
|
||||
return changed ? result : record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively merge children into the tree at the matching directory path.
|
||||
*/
|
||||
function mergeChildrenIntoTree(
|
||||
tree: FileTreeEntry[],
|
||||
targetPath: string,
|
||||
children: FileTreeEntry[]
|
||||
): FileTreeEntry[] {
|
||||
return tree.map((entry) => {
|
||||
if (entry.path === targetPath && entry.type === 'directory') {
|
||||
return { ...entry, children };
|
||||
}
|
||||
if (entry.children) {
|
||||
return {
|
||||
...entry,
|
||||
children: mergeChildrenIntoTree(entry.children, targetPath, children),
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import type { ConfigSlice } from './slices/configSlice';
|
|||
import type { ConnectionSlice } from './slices/connectionSlice';
|
||||
import type { ContextSlice } from './slices/contextSlice';
|
||||
import type { ConversationSlice } from './slices/conversationSlice';
|
||||
import type { EditorSlice } from './slices/editorSlice';
|
||||
import type { NotificationSlice } from './slices/notificationSlice';
|
||||
import type { PaneSlice } from './slices/paneSlice';
|
||||
import type { ProjectSlice } from './slices/projectSlice';
|
||||
|
|
@ -96,4 +97,5 @@ export type AppState = ProjectSlice &
|
|||
ContextSlice &
|
||||
UpdateSlice &
|
||||
ChangeReviewSlice &
|
||||
CliInstallerSlice;
|
||||
CliInstallerSlice &
|
||||
EditorSlice;
|
||||
|
|
|
|||
80
src/renderer/utils/buildSelectionAction.ts
Normal file
80
src/renderer/utils/buildSelectionAction.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Builds an EditorSelectionAction from a selection info + action type.
|
||||
*
|
||||
* Extracted as a utility so it can be imported in tests
|
||||
* without pulling in CodeMirror dependencies.
|
||||
*/
|
||||
|
||||
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Code fence language map (lowercase identifiers for markdown)
|
||||
// =============================================================================
|
||||
|
||||
const CODE_FENCE_LANG: Record<string, string> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
py: 'python',
|
||||
json: 'json',
|
||||
jsonl: 'json',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
sass: 'sass',
|
||||
less: 'less',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
xml: 'xml',
|
||||
svg: 'xml',
|
||||
md: 'markdown',
|
||||
mdx: 'markdown',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
rs: 'rust',
|
||||
go: 'go',
|
||||
java: 'java',
|
||||
c: 'c',
|
||||
h: 'c',
|
||||
cpp: 'cpp',
|
||||
cxx: 'cpp',
|
||||
cc: 'cpp',
|
||||
hpp: 'cpp',
|
||||
php: 'php',
|
||||
sql: 'sql',
|
||||
sh: 'bash',
|
||||
bash: 'bash',
|
||||
zsh: 'bash',
|
||||
toml: 'toml',
|
||||
ini: 'ini',
|
||||
};
|
||||
|
||||
/** Maps file extension to a code fence language identifier (lowercase). */
|
||||
export function getCodeFenceLanguage(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
|
||||
return CODE_FENCE_LANG[ext] ?? '';
|
||||
}
|
||||
|
||||
/** Builds a selection action with a formatted markdown code fence context. */
|
||||
export function buildSelectionAction(
|
||||
type: EditorSelectionAction['type'],
|
||||
info: EditorSelectionInfo
|
||||
): EditorSelectionAction {
|
||||
const fileName = info.filePath.split('/').pop() ?? 'file';
|
||||
const lang = getCodeFenceLanguage(fileName);
|
||||
const lineRef =
|
||||
info.fromLine === info.toLine
|
||||
? `line ${info.fromLine}`
|
||||
: `lines ${info.fromLine}-${info.toLine}`;
|
||||
const formattedContext = `**${fileName}** (${lineRef}):\n\`\`\`${lang}\n${info.text}\n\`\`\``;
|
||||
return {
|
||||
type,
|
||||
filePath: info.filePath,
|
||||
fromLine: info.fromLine,
|
||||
toLine: info.toLine,
|
||||
selectedText: info.text,
|
||||
formattedContext,
|
||||
};
|
||||
}
|
||||
141
src/renderer/utils/codemirrorLanguages.ts
Normal file
141
src/renderer/utils/codemirrorLanguages.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* CodeMirror 6 language support — synchronous (bundled) + async fallback.
|
||||
*
|
||||
* Extracted from CodeMirrorDiffView.tsx for reuse by editor and diff views.
|
||||
*/
|
||||
|
||||
import { cpp } from '@codemirror/lang-cpp';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { go } from '@codemirror/lang-go';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { java } from '@codemirror/lang-java';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { less } from '@codemirror/lang-less';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { php } from '@codemirror/lang-php';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { rust } from '@codemirror/lang-rust';
|
||||
import { sass } from '@codemirror/lang-sass';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { LanguageDescription } from '@codemirror/language';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
|
||||
import type { Extension } from '@codemirror/state';
|
||||
|
||||
/** Synchronous language extension for common file types (bundled by Vite) */
|
||||
export function getSyncLanguageExtension(fileName: string): Extension | null {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return javascript({
|
||||
jsx: ext === 'tsx' || ext === 'jsx',
|
||||
typescript: ext === 'ts' || ext === 'tsx',
|
||||
});
|
||||
case 'py':
|
||||
return python();
|
||||
case 'json':
|
||||
case 'jsonl':
|
||||
return json();
|
||||
case 'css':
|
||||
return css();
|
||||
case 'scss':
|
||||
return sass({ indented: false });
|
||||
case 'sass':
|
||||
return sass({ indented: true });
|
||||
case 'less':
|
||||
return less();
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return html();
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
return xml();
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
case 'markdown':
|
||||
return markdown();
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return yaml();
|
||||
case 'rs':
|
||||
return rust();
|
||||
case 'go':
|
||||
return go();
|
||||
case 'java':
|
||||
return java();
|
||||
case 'c':
|
||||
case 'h':
|
||||
case 'cpp':
|
||||
case 'cxx':
|
||||
case 'cc':
|
||||
case 'hpp':
|
||||
return cpp();
|
||||
case 'php':
|
||||
return php();
|
||||
case 'sql':
|
||||
return sql();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Async fallback: match by filename via @codemirror/language-data for rare languages */
|
||||
export function getAsyncLanguageDesc(fileName: string): LanguageDescription | null {
|
||||
return LanguageDescription.matchFilename(languages, fileName);
|
||||
}
|
||||
|
||||
/** Human-readable language name from file extension (for status bar / tab labels) */
|
||||
export function getLanguageFromFileName(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
ts: 'TypeScript',
|
||||
tsx: 'TypeScript (JSX)',
|
||||
js: 'JavaScript',
|
||||
jsx: 'JavaScript (JSX)',
|
||||
mjs: 'JavaScript',
|
||||
cjs: 'JavaScript',
|
||||
py: 'Python',
|
||||
json: 'JSON',
|
||||
jsonl: 'JSON Lines',
|
||||
css: 'CSS',
|
||||
scss: 'SCSS',
|
||||
sass: 'Sass',
|
||||
less: 'Less',
|
||||
html: 'HTML',
|
||||
htm: 'HTML',
|
||||
xml: 'XML',
|
||||
svg: 'SVG',
|
||||
md: 'Markdown',
|
||||
mdx: 'MDX',
|
||||
markdown: 'Markdown',
|
||||
yaml: 'YAML',
|
||||
yml: 'YAML',
|
||||
rs: 'Rust',
|
||||
go: 'Go',
|
||||
java: 'Java',
|
||||
c: 'C',
|
||||
h: 'C/C++ Header',
|
||||
cpp: 'C++',
|
||||
cxx: 'C++',
|
||||
cc: 'C++',
|
||||
hpp: 'C++ Header',
|
||||
php: 'PHP',
|
||||
sql: 'SQL',
|
||||
sh: 'Shell',
|
||||
bash: 'Bash',
|
||||
zsh: 'Zsh',
|
||||
toml: 'TOML',
|
||||
ini: 'INI',
|
||||
conf: 'Config',
|
||||
txt: 'Plain Text',
|
||||
};
|
||||
return map[ext ?? ''] ?? 'Plain Text';
|
||||
}
|
||||
52
src/renderer/utils/codemirrorTheme.ts
Normal file
52
src/renderer/utils/codemirrorTheme.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Base CodeMirror 6 theme using CSS variables.
|
||||
*
|
||||
* Extracted from CodeMirrorDiffView.tsx — shared between diff view and project editor.
|
||||
* Diff-specific styles (changedLine, deletedChunk, merge toolbar) stay in CodeMirrorDiffView.
|
||||
*/
|
||||
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
/** Base editor theme — general styling without diff-specific rules */
|
||||
export const baseEditorTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: '13px',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderRight: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '11px',
|
||||
minWidth: 'auto',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
padding: '0 4px 0 8px',
|
||||
minWidth: '2ch',
|
||||
textAlign: 'right',
|
||||
opacity: '0.5',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--color-text)',
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: 'var(--color-text)',
|
||||
},
|
||||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
||||
},
|
||||
});
|
||||
90
src/renderer/utils/editorBridge.ts
Normal file
90
src/renderer/utils/editorBridge.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Module-level singleton bridging Zustand store ↔ CodeMirror refs.
|
||||
*
|
||||
* CodeMirrorEditor calls register() on mount, unregister() on unmount.
|
||||
* Store actions (saveFile, saveAllFiles, closeEditor) use getContent()/destroy().
|
||||
*
|
||||
* Pattern: analogous to ConfirmDialog.tsx (module-level globalSetState).
|
||||
*/
|
||||
|
||||
import type { EditorState } from '@codemirror/state';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
|
||||
let stateCache: Map<string, EditorState> | null = null;
|
||||
let scrollTopCache: Map<string, number> | null = null;
|
||||
let activeView: EditorView | null = null;
|
||||
|
||||
export const editorBridge = {
|
||||
/** Called by CodeMirrorEditor on mount */
|
||||
register(sc: Map<string, EditorState>, stc: Map<string, number>, view: EditorView): void {
|
||||
stateCache = sc;
|
||||
scrollTopCache = stc;
|
||||
activeView = view;
|
||||
},
|
||||
|
||||
/** Called by CodeMirrorEditor on unmount */
|
||||
unregister(): void {
|
||||
stateCache = null;
|
||||
scrollTopCache = null;
|
||||
activeView = null;
|
||||
},
|
||||
|
||||
/** Check if bridge is registered (HMR guard) */
|
||||
get isRegistered(): boolean {
|
||||
return stateCache !== null;
|
||||
},
|
||||
|
||||
/** Get content for a single file from cached EditorState */
|
||||
getContent(filePath: string): string | null {
|
||||
return stateCache?.get(filePath)?.doc.toString() ?? null;
|
||||
},
|
||||
|
||||
/** Get content for all modified files */
|
||||
getAllModifiedContent(modifiedFiles: Record<string, boolean>): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const fp of Object.keys(modifiedFiles)) {
|
||||
if (!modifiedFiles[fp]) continue;
|
||||
const content = stateCache?.get(fp)?.doc.toString();
|
||||
if (content !== undefined) result.set(fp, content);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Remove cached state for a single tab — called by closeTab() */
|
||||
deleteState(tabId: string): void {
|
||||
stateCache?.delete(tabId);
|
||||
scrollTopCache?.delete(tabId);
|
||||
},
|
||||
|
||||
/** Full cleanup — called by closeEditor() */
|
||||
destroy(): void {
|
||||
activeView?.destroy();
|
||||
stateCache?.clear();
|
||||
scrollTopCache?.clear();
|
||||
activeView = null;
|
||||
},
|
||||
|
||||
/** Remap cached state from oldPath to newPath (used by moveFileInTree) */
|
||||
remapState(oldPath: string, newPath: string): void {
|
||||
const state = stateCache?.get(oldPath);
|
||||
if (state) {
|
||||
stateCache!.delete(oldPath);
|
||||
stateCache!.set(newPath, state);
|
||||
}
|
||||
const scroll = scrollTopCache?.get(oldPath);
|
||||
if (scroll !== undefined) {
|
||||
scrollTopCache!.delete(oldPath);
|
||||
scrollTopCache!.set(newPath, scroll);
|
||||
}
|
||||
},
|
||||
|
||||
/** Update view reference (on tab switch, view may be recreated) */
|
||||
updateView(view: EditorView): void {
|
||||
activeView = view;
|
||||
},
|
||||
|
||||
/** Get current EditorView (for undo/redo toolbar) */
|
||||
getView(): EditorView | null {
|
||||
return activeView;
|
||||
},
|
||||
};
|
||||
82
src/renderer/utils/fileTreeBuilder.ts
Normal file
82
src/renderer/utils/fileTreeBuilder.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Generic tree builder — converts a flat list of items with paths
|
||||
* into a hierarchical tree structure with single-child directory collapsing.
|
||||
*
|
||||
* Used by ReviewFileTree (FileChangeSummary) and EditorFileTree (FileTreeEntry).
|
||||
*/
|
||||
|
||||
export interface TreeNode<T> {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
isFile: boolean;
|
||||
data?: T;
|
||||
children: TreeNode<T>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a hierarchical tree from a flat list of items.
|
||||
*
|
||||
* @param items - Flat list of items (files/entries)
|
||||
* @param getPath - Extract relative path from item (using '/' separator)
|
||||
* @param options.collapse - Merge single-child intermediate directories (default: true)
|
||||
*/
|
||||
export function buildTree<T>(
|
||||
items: T[],
|
||||
getPath: (item: T) => string,
|
||||
options?: { collapse?: boolean }
|
||||
): TreeNode<T>[] {
|
||||
const root: TreeNode<T> = { name: '', fullPath: '', isFile: false, children: [] };
|
||||
|
||||
for (const item of items) {
|
||||
const parts = getPath(item).split('/');
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
const fullPath = parts.slice(0, i + 1).join('/');
|
||||
|
||||
let child = current.children.find((c) => c.name === part);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: part,
|
||||
fullPath,
|
||||
isFile: isLast,
|
||||
data: isLast ? item : undefined,
|
||||
children: [],
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.collapse === false) {
|
||||
return root.children;
|
||||
}
|
||||
|
||||
// Collapse children individually — root itself has empty name and must not participate
|
||||
return root.children.map(collapseTree);
|
||||
}
|
||||
|
||||
/** Merge single-child intermediate directories: a/ → b/ → c becomes a/b/c */
|
||||
function collapseTree<T>(node: TreeNode<T>): TreeNode<T> {
|
||||
const collapsed: TreeNode<T> = { ...node, children: node.children.map(collapseTree) };
|
||||
if (!collapsed.isFile && collapsed.children.length === 1 && !collapsed.children[0].isFile) {
|
||||
const child = collapsed.children[0];
|
||||
return {
|
||||
...child,
|
||||
name: `${collapsed.name}/${child.name}`,
|
||||
children: child.children,
|
||||
};
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
/** Sort tree nodes: directories first, then alphabetical */
|
||||
export function sortTreeNodes<T>(nodes: TreeNode<T>[]): TreeNode<T>[] {
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
24
src/renderer/utils/platformKeys.ts
Normal file
24
src/renderer/utils/platformKeys.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Cross-platform keyboard shortcut display helpers.
|
||||
*
|
||||
* Mac shows symbols (cmd, shift, option, ctrl), Windows/Linux shows words (Ctrl+, Shift+, Alt+).
|
||||
*/
|
||||
|
||||
function detectMac(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
// Prefer userAgentData (modern API) over deprecated navigator.platform
|
||||
const platform =
|
||||
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ??
|
||||
navigator.userAgent;
|
||||
return /mac/i.test(platform);
|
||||
}
|
||||
|
||||
export const IS_MAC = detectMac();
|
||||
|
||||
/** Return platform-appropriate modifier prefix: "cmd" on Mac, "Ctrl+" on others */
|
||||
export const MOD = IS_MAC ? '\u2318' : 'Ctrl+';
|
||||
|
||||
/** Return platform-appropriate shortcut string */
|
||||
export function shortcutLabel(mac: string, other: string): string {
|
||||
return IS_MAC ? mac : other;
|
||||
}
|
||||
97
src/renderer/utils/tabLabelDisambiguation.ts
Normal file
97
src/renderer/utils/tabLabelDisambiguation.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Tab label disambiguation — adds suffix labels when multiple tabs share the same file name.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Group tabs by fileName
|
||||
* 2. For groups with >1 tab, find the minimal unique path suffix
|
||||
* 3. Format as "(parent/dir)" — e.g. "(main/utils)", "(renderer/hooks)"
|
||||
* 4. Unique file names get no label (disambiguatedLabel = undefined)
|
||||
*/
|
||||
|
||||
import type { EditorFileTab } from '@shared/types/editor';
|
||||
|
||||
/**
|
||||
* Compute disambiguated labels for all tabs.
|
||||
* Returns a new array with `disambiguatedLabel` set where needed.
|
||||
*/
|
||||
export function computeDisambiguatedTabs(tabs: EditorFileTab[]): EditorFileTab[] {
|
||||
if (tabs.length === 0) return tabs;
|
||||
|
||||
// Single tab — just clear any stale label
|
||||
if (tabs.length === 1) {
|
||||
const tab = tabs[0];
|
||||
if (tab.disambiguatedLabel === undefined) return tabs;
|
||||
return [{ ...tab, disambiguatedLabel: undefined }];
|
||||
}
|
||||
|
||||
// Group tabs by fileName
|
||||
const groups = new Map<string, EditorFileTab[]>();
|
||||
for (const tab of tabs) {
|
||||
const existing = groups.get(tab.fileName);
|
||||
if (existing) {
|
||||
existing.push(tab);
|
||||
} else {
|
||||
groups.set(tab.fileName, [tab]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of tabId → disambiguatedLabel
|
||||
const labels = new Map<string, string | undefined>();
|
||||
|
||||
for (const [, group] of groups) {
|
||||
if (group.length <= 1) {
|
||||
// Unique name — no label needed
|
||||
for (const tab of group) {
|
||||
labels.set(tab.id, undefined);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split paths into segments for comparison
|
||||
const pathSegments = group.map((tab) => {
|
||||
const parts = tab.filePath.split('/');
|
||||
// Remove the file name (last segment)
|
||||
parts.pop();
|
||||
return parts;
|
||||
});
|
||||
|
||||
// Find minimal unique suffix depth
|
||||
// Start from depth=1 (immediate parent) and go deeper until all labels are unique
|
||||
let depth = 1;
|
||||
const maxDepth = Math.max(...pathSegments.map((s) => s.length));
|
||||
|
||||
while (depth <= maxDepth) {
|
||||
const suffixes = pathSegments.map((parts) => {
|
||||
const start = Math.max(0, parts.length - depth);
|
||||
return parts.slice(start).join('/');
|
||||
});
|
||||
|
||||
// Check if all suffixes are unique
|
||||
const unique = new Set(suffixes);
|
||||
if (unique.size === suffixes.length) {
|
||||
// All unique — assign labels
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
labels.set(group[i].id, `(${suffixes[i]})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
depth++;
|
||||
}
|
||||
|
||||
// If we couldn't find unique suffixes (shouldn't happen with different file paths),
|
||||
// use full parent path
|
||||
if (depth > maxDepth) {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const fullParent = pathSegments[i].join('/');
|
||||
labels.set(group[i].id, `(${fullParent})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply labels to tabs
|
||||
return tabs.map((tab) => {
|
||||
const label = labels.get(tab.id);
|
||||
if (label === tab.disambiguatedLabel) return tab;
|
||||
return { ...tab, disambiguatedLabel: label };
|
||||
});
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
|
||||
import type { CliInstallerAPI } from './cliInstaller';
|
||||
import type { EditorAPI } from './editor';
|
||||
import type {
|
||||
AppConfig,
|
||||
DetectedError,
|
||||
|
|
@ -638,6 +639,9 @@ export interface ElectronAPI {
|
|||
|
||||
// Embedded Terminal API (xterm.js + node-pty)
|
||||
terminal: TerminalAPI;
|
||||
|
||||
// Project Editor API (file browser + CodeMirror)
|
||||
editor: EditorAPI;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
214
src/shared/types/editor.ts
Normal file
214
src/shared/types/editor.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Editor types shared between main and renderer processes.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// File Tree
|
||||
// =============================================================================
|
||||
|
||||
export interface FileTreeEntry {
|
||||
name: string;
|
||||
/** Absolute path */
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
/** File size in bytes (files only) */
|
||||
size?: number;
|
||||
/** True for .env, .key, credentials, etc. — shown with lock icon */
|
||||
isSensitive?: boolean;
|
||||
/** Lazy-loaded children (populated on expand) */
|
||||
children?: FileTreeEntry[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IPC Results
|
||||
// =============================================================================
|
||||
|
||||
export interface ReadDirResult {
|
||||
entries: FileTreeEntry[];
|
||||
/** True when entries were truncated at MAX_DIR_ENTRIES */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface ReadFileResult {
|
||||
content: string;
|
||||
size: number;
|
||||
/** Unix timestamp (stats.mtimeMs) — baseline for conflict detection */
|
||||
mtimeMs: number;
|
||||
/** True when file was too large and only preview was returned */
|
||||
truncated: boolean;
|
||||
encoding: string;
|
||||
isBinary: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Write Request/Response
|
||||
// =============================================================================
|
||||
|
||||
export interface WriteFileRequest {
|
||||
filePath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface WriteFileResponse {
|
||||
/** Unix timestamp after write (new mtimeMs) */
|
||||
mtimeMs: number;
|
||||
/** Bytes written */
|
||||
size: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// File Operations
|
||||
// =============================================================================
|
||||
|
||||
export interface CreateFileResponse {
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface CreateDirResponse {
|
||||
dirPath: string;
|
||||
}
|
||||
|
||||
export interface DeleteFileResponse {
|
||||
deletedPath: string;
|
||||
}
|
||||
|
||||
export interface MoveFileResponse {
|
||||
newPath: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Search
|
||||
// =============================================================================
|
||||
|
||||
export interface SearchMatch {
|
||||
/** 1-based line number */
|
||||
line: number;
|
||||
/** 0-based column offset */
|
||||
column: number;
|
||||
/** The matching line text (trimmed) */
|
||||
lineContent: string;
|
||||
}
|
||||
|
||||
export interface SearchFileResult {
|
||||
filePath: string;
|
||||
matches: SearchMatch[];
|
||||
}
|
||||
|
||||
export interface SearchInFilesResult {
|
||||
results: SearchFileResult[];
|
||||
/** Total number of matches across all files */
|
||||
totalMatches: number;
|
||||
/** True when results were truncated at limit */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface SearchInFilesOptions {
|
||||
query: string;
|
||||
caseSensitive?: boolean;
|
||||
/** Maximum number of result files (default 100) */
|
||||
maxFiles?: number;
|
||||
/** Maximum number of total matches (default 500) */
|
||||
maxMatches?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tab
|
||||
// =============================================================================
|
||||
|
||||
export interface EditorFileTab {
|
||||
/** Unique key = filePath */
|
||||
id: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
/** Disambiguation suffix for duplicate names, e.g. "(main/utils)" */
|
||||
disambiguatedLabel?: string;
|
||||
/** Language identifier (from file extension) */
|
||||
language: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Git Status
|
||||
// =============================================================================
|
||||
|
||||
export type GitFileStatusType =
|
||||
| 'modified'
|
||||
| 'untracked'
|
||||
| 'staged'
|
||||
| 'deleted'
|
||||
| 'conflict'
|
||||
| 'renamed';
|
||||
|
||||
export interface GitFileStatus {
|
||||
/** Relative path from project root */
|
||||
path: string;
|
||||
status: GitFileStatusType;
|
||||
/** Original path for renamed files */
|
||||
renamedFrom?: string;
|
||||
}
|
||||
|
||||
export interface GitStatusResult {
|
||||
files: GitFileStatus[];
|
||||
/** True if the project is inside a git repository */
|
||||
isGitRepo: boolean;
|
||||
/** Branch name (null if detached HEAD) */
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// File Watcher Events
|
||||
// =============================================================================
|
||||
|
||||
export interface EditorFileChangeEvent {
|
||||
type: 'change' | 'create' | 'delete';
|
||||
/** Absolute path of the changed file */
|
||||
path: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Editor API
|
||||
// =============================================================================
|
||||
|
||||
export interface EditorAPI {
|
||||
open: (projectPath: string) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
readDir: (dirPath: string, maxEntries?: number) => Promise<ReadDirResult>;
|
||||
readFile: (filePath: string) => Promise<ReadFileResult>;
|
||||
writeFile: (
|
||||
filePath: string,
|
||||
content: string,
|
||||
baselineMtimeMs?: number
|
||||
) => Promise<WriteFileResponse>;
|
||||
createFile: (parentDir: string, fileName: string) => Promise<CreateFileResponse>;
|
||||
createDir: (parentDir: string, dirName: string) => Promise<CreateDirResponse>;
|
||||
deleteFile: (filePath: string) => Promise<DeleteFileResponse>;
|
||||
moveFile: (sourcePath: string, destDir: string) => Promise<MoveFileResponse>;
|
||||
searchInFiles: (options: SearchInFilesOptions) => Promise<SearchInFilesResult>;
|
||||
gitStatus: () => Promise<GitStatusResult>;
|
||||
watchDir: (enable: boolean) => Promise<void>;
|
||||
/** Subscribe to file change events (main → renderer). Returns cleanup function. */
|
||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Selection Action Menu
|
||||
// =============================================================================
|
||||
|
||||
export interface EditorSelectionInfo {
|
||||
text: string;
|
||||
filePath: string;
|
||||
fromLine: number;
|
||||
toLine: number;
|
||||
/** Screen coords of selection end (for menu positioning) */
|
||||
screenRect: { top: number; right: number; bottom: number };
|
||||
}
|
||||
|
||||
export interface EditorSelectionAction {
|
||||
type: 'sendMessage' | 'createTask';
|
||||
filePath: string;
|
||||
fromLine: number;
|
||||
toLine: number;
|
||||
selectedText: string;
|
||||
/** Pre-formatted context block (markdown code fence) */
|
||||
formattedContext: string;
|
||||
}
|
||||
|
|
@ -35,3 +35,6 @@ export type * from './cliInstaller';
|
|||
|
||||
// Re-export Terminal types
|
||||
export type * from './terminal';
|
||||
|
||||
// Re-export Editor types
|
||||
export type * from './editor';
|
||||
|
|
|
|||
384
test/main/ipc/editor.test.ts
Normal file
384
test/main/ipc/editor.test.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
/**
|
||||
* Tests for editor IPC handlers — validation, security, module-level state.
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
|
||||
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
|
||||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
lstat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock isbinaryfile
|
||||
vi.mock('isbinaryfile', () => ({
|
||||
isBinaryFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock IPC channels
|
||||
vi.mock('@preload/constants/ipcChannels', () => ({
|
||||
EDITOR_OPEN: 'editor:open',
|
||||
EDITOR_CLOSE: 'editor:close',
|
||||
EDITOR_READ_DIR: 'editor:readDir',
|
||||
EDITOR_READ_FILE: 'editor:readFile',
|
||||
EDITOR_WRITE_FILE: 'editor:writeFile',
|
||||
EDITOR_CREATE_FILE: 'editor:createFile',
|
||||
EDITOR_CREATE_DIR: 'editor:createDir',
|
||||
EDITOR_DELETE_FILE: 'editor:deleteFile',
|
||||
EDITOR_MOVE_FILE: 'editor:moveFile',
|
||||
EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles',
|
||||
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
||||
EDITOR_WATCH_DIR: 'editor:watchDir',
|
||||
EDITOR_CHANGE: 'editor:change',
|
||||
}));
|
||||
|
||||
// Mock atomicWrite used by ProjectFileService
|
||||
vi.mock('@main/utils/atomicWrite', () => ({
|
||||
atomicWriteAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock simple-git (used by GitStatusService)
|
||||
vi.mock('simple-git', () => {
|
||||
const mockGit = {
|
||||
status: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
env: vi.fn().mockReturnThis(),
|
||||
};
|
||||
return { simpleGit: vi.fn(() => mockGit) };
|
||||
});
|
||||
|
||||
// Mock chokidar (used by EditorFileWatcher)
|
||||
vi.mock('chokidar', () => ({
|
||||
watch: vi.fn(() => ({
|
||||
on: vi.fn().mockReturnThis(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock pathDecoder
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getClaudeBasePath: () => path.join(os.homedir(), '.claude'),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import {
|
||||
cleanupEditorState,
|
||||
initializeEditorHandlers,
|
||||
registerEditorHandlers,
|
||||
removeEditorHandlers,
|
||||
} from '../../../src/main/ipc/editor';
|
||||
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function createMockIpcMain() {
|
||||
const handlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
return {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
removeHandler: vi.fn((channel: string) => {
|
||||
handlers.delete(channel);
|
||||
}),
|
||||
invoke: async (channel: string, ...args: unknown[]) => {
|
||||
const handler = handlers.get(channel);
|
||||
if (!handler) throw new Error(`No handler for ${channel}`);
|
||||
return handler({} as IpcMainInvokeEvent, ...args);
|
||||
},
|
||||
_handlers: handlers,
|
||||
};
|
||||
}
|
||||
|
||||
function createStats(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Awaited<ReturnType<typeof fs.stat>> {
|
||||
return {
|
||||
isFile: () => overrides.isFile ?? false,
|
||||
isDirectory: () => overrides.isDirectory ?? true,
|
||||
isSymbolicLink: () => overrides.isSymbolicLink ?? false,
|
||||
size: overrides.size ?? 1024,
|
||||
mtimeMs: overrides.mtimeMs ?? Date.now(),
|
||||
} as Awaited<ReturnType<typeof fs.stat>>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('Editor IPC handlers', () => {
|
||||
let mockIpc: ReturnType<typeof createMockIpcMain>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockIpc = createMockIpcMain();
|
||||
initializeEditorHandlers();
|
||||
registerEditorHandlers(mockIpc as unknown as IpcMain);
|
||||
// Always start with clean state
|
||||
cleanupEditorState();
|
||||
});
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers all 12 editor channels', () => {
|
||||
expect(mockIpc.handle).toHaveBeenCalledTimes(12);
|
||||
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:readDir')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:readFile')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:writeFile')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:createFile')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:createDir')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:deleteFile')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:moveFile')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:searchInFiles')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:gitStatus')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
|
||||
});
|
||||
|
||||
it('removeEditorHandlers clears all channels', () => {
|
||||
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:open', () => {
|
||||
it('accepts valid absolute directory path', async () => {
|
||||
const projectPath = '/Users/test/my-project';
|
||||
vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true }));
|
||||
|
||||
const result = await mockIpc.invoke('editor:open', projectPath);
|
||||
|
||||
expect(result).toEqual({ success: true, data: undefined });
|
||||
});
|
||||
|
||||
it('rejects empty path', async () => {
|
||||
const result = await mockIpc.invoke('editor:open', '');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('Invalid project path'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects relative path', async () => {
|
||||
const result = await mockIpc.invoke('editor:open', 'relative/path');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('must be absolute'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects filesystem root (SEC-15)', async () => {
|
||||
const result = await mockIpc.invoke('editor:open', '/');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('filesystem root'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects ~/.claude directory (SEC-15)', async () => {
|
||||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
const result = await mockIpc.invoke('editor:open', claudeDir);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('Claude data directory'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects path to a file (not directory)', async () => {
|
||||
vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: false, isFile: true }));
|
||||
|
||||
const result = await mockIpc.invoke('editor:open', '/Users/test/file.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not a directory'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-existent path', async () => {
|
||||
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await mockIpc.invoke('editor:open', '/nonexistent/path');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('ENOENT'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:close', () => {
|
||||
it('resets state successfully', async () => {
|
||||
// Open first
|
||||
vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true }));
|
||||
await mockIpc.invoke('editor:open', '/Users/test/project');
|
||||
|
||||
const result = await mockIpc.invoke('editor:close');
|
||||
|
||||
expect(result).toEqual({ success: true, data: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:readDir', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:readDir', '/some/path');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
|
||||
it('works after editor:open', async () => {
|
||||
// Open project
|
||||
vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true }));
|
||||
await mockIpc.invoke('editor:open', '/Users/test/project');
|
||||
|
||||
// Mock readDir
|
||||
vi.mocked(fs.lstat).mockResolvedValue(createStats({ isDirectory: true }) as never);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([] as never);
|
||||
|
||||
const result = await mockIpc.invoke('editor:readDir', '/Users/test/project');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { entries: [], truncated: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:readFile', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:readFile', '/some/file.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:createFile', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:createFile', '/some/path', 'file.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:createDir', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:createDir', '/some/path', 'new-dir');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:deleteFile', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:deleteFile', '/some/file.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:moveFile', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:moveFile', '/some/file.ts', '/other/dir');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:searchInFiles', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:searchInFiles', { query: 'test' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:gitStatus', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:gitStatus');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor:watchDir', () => {
|
||||
it('rejects if editor not initialized', async () => {
|
||||
const result = await mockIpc.invoke('editor:watchDir', true);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupEditorState', () => {
|
||||
it('resets state so readDir fails with not initialized', async () => {
|
||||
// Open project
|
||||
vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true }));
|
||||
await mockIpc.invoke('editor:open', '/Users/test/project');
|
||||
|
||||
// Cleanup
|
||||
cleanupEditorState();
|
||||
|
||||
// Now readDir should fail
|
||||
const result = await mockIpc.invoke('editor:readDir', '/Users/test/project');
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('not initialized'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
80
test/main/ipc/ipcWrapper.test.ts
Normal file
80
test/main/ipc/ipcWrapper.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createIpcWrapper } from '@main/ipc/ipcWrapper';
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('createIpcWrapper', () => {
|
||||
it('returns success result on successful handler', async () => {
|
||||
const wrap = createIpcWrapper('test');
|
||||
const result = await wrap('op', async () => 42);
|
||||
|
||||
expect(result).toEqual({ success: true, data: 42 });
|
||||
});
|
||||
|
||||
it('returns success with complex data', async () => {
|
||||
const wrap = createIpcWrapper('test');
|
||||
const data = { items: [1, 2, 3], meta: { count: 3 } };
|
||||
const result = await wrap('op', async () => data);
|
||||
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
|
||||
it('returns error result when handler throws Error', async () => {
|
||||
const wrap = createIpcWrapper('test');
|
||||
const result = await wrap('op', async () => {
|
||||
throw new Error('Something went wrong');
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error result when handler throws non-Error', async () => {
|
||||
const wrap = createIpcWrapper('test');
|
||||
const result = await wrap('op', async () => {
|
||||
throw 'string error';
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'string error',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles void return', async () => {
|
||||
const wrap = createIpcWrapper('test');
|
||||
const result = await wrap('op', async () => {
|
||||
// void
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, data: undefined });
|
||||
});
|
||||
|
||||
it('handles null return', async () => {
|
||||
const wrap = createIpcWrapper('test');
|
||||
const result = await wrap('op', async () => null);
|
||||
|
||||
expect(result).toEqual({ success: true, data: null });
|
||||
});
|
||||
|
||||
it('creates independent wrappers with different prefixes', async () => {
|
||||
const wrap1 = createIpcWrapper('prefix1');
|
||||
const wrap2 = createIpcWrapper('prefix2');
|
||||
|
||||
const result1 = await wrap1('op', async () => 'a');
|
||||
const result2 = await wrap2('op', async () => 'b');
|
||||
|
||||
expect(result1).toEqual({ success: true, data: 'a' });
|
||||
expect(result2).toEqual({ success: true, data: 'b' });
|
||||
});
|
||||
});
|
||||
170
test/main/services/editor/EditorFileWatcher.test.ts
Normal file
170
test/main/services/editor/EditorFileWatcher.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Tests for EditorFileWatcher — start/stop, event filtering, path security.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock chokidar
|
||||
const mockOn = vi.fn().mockReturnThis();
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock('chokidar', () => ({
|
||||
watch: vi.fn(() => ({
|
||||
on: mockOn,
|
||||
close: mockClose,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/pathValidation', () => ({
|
||||
isPathWithinRoot: vi.fn((filePath: string, root: string) => {
|
||||
return filePath.startsWith(root);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { watch } from 'chokidar';
|
||||
|
||||
import { isPathWithinRoot } from '../../../../src/main/utils/pathValidation';
|
||||
import { EditorFileWatcher } from '../../../../src/main/services/editor/EditorFileWatcher';
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('EditorFileWatcher', () => {
|
||||
let watcher: EditorFileWatcher;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockOn.mockReturnThis();
|
||||
watcher = new EditorFileWatcher();
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('creates chokidar watcher with correct options', () => {
|
||||
const onChange = vi.fn();
|
||||
watcher.start('/Users/test/project', onChange);
|
||||
|
||||
expect(watch).toHaveBeenCalledWith('/Users/test/project', {
|
||||
ignored: expect.any(RegExp),
|
||||
ignoreInitial: true,
|
||||
followSymlinks: false,
|
||||
depth: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('registers change, add, unlink, and error handlers', () => {
|
||||
const onChange = vi.fn();
|
||||
watcher.start('/Users/test/project', onChange);
|
||||
|
||||
const registeredEvents = mockOn.mock.calls.map((c) => c[0]);
|
||||
expect(registeredEvents).toContain('change');
|
||||
expect(registeredEvents).toContain('add');
|
||||
expect(registeredEvents).toContain('unlink');
|
||||
expect(registeredEvents).toContain('error');
|
||||
});
|
||||
|
||||
it('emits normalized events through onChange callback', () => {
|
||||
const onChange = vi.fn();
|
||||
watcher.start('/Users/test/project', onChange);
|
||||
|
||||
// Simulate chokidar 'change' event
|
||||
const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1];
|
||||
changeHandler?.('/Users/test/project/src/index.ts');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
type: 'change',
|
||||
path: '/Users/test/project/src/index.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits create event for add', () => {
|
||||
const onChange = vi.fn();
|
||||
watcher.start('/Users/test/project', onChange);
|
||||
|
||||
const addHandler = mockOn.mock.calls.find((c) => c[0] === 'add')?.[1];
|
||||
addHandler?.('/Users/test/project/new-file.ts');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
type: 'create',
|
||||
path: '/Users/test/project/new-file.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits delete event for unlink', () => {
|
||||
const onChange = vi.fn();
|
||||
watcher.start('/Users/test/project', onChange);
|
||||
|
||||
const unlinkHandler = mockOn.mock.calls.find((c) => c[0] === 'unlink')?.[1];
|
||||
unlinkHandler?.('/Users/test/project/old-file.ts');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
type: 'delete',
|
||||
path: '/Users/test/project/old-file.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores events outside project root (SEC-2)', () => {
|
||||
vi.mocked(isPathWithinRoot).mockReturnValueOnce(false);
|
||||
|
||||
const onChange = vi.fn();
|
||||
watcher.start('/Users/test/project', onChange);
|
||||
|
||||
const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1];
|
||||
changeHandler?.('/etc/passwd');
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops previous watcher on re-start (idempotent)', () => {
|
||||
const onChange = vi.fn();
|
||||
watcher.start('/Users/test/project1', onChange);
|
||||
watcher.start('/Users/test/project2', onChange);
|
||||
|
||||
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||
expect(watch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('closes the watcher', () => {
|
||||
const onChange = vi.fn();
|
||||
watcher.start('/Users/test/project', onChange);
|
||||
|
||||
watcher.stop();
|
||||
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is safe to call multiple times', () => {
|
||||
watcher.stop();
|
||||
watcher.stop();
|
||||
// No error thrown
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWatching', () => {
|
||||
it('returns false when not started', () => {
|
||||
expect(watcher.isWatching()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true after start', () => {
|
||||
watcher.start('/Users/test/project', vi.fn());
|
||||
expect(watcher.isWatching()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false after stop', () => {
|
||||
watcher.start('/Users/test/project', vi.fn());
|
||||
watcher.stop();
|
||||
expect(watcher.isWatching()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
170
test/main/services/editor/FileSearchService.test.ts
Normal file
170
test/main/services/editor/FileSearchService.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Tests for FileSearchService — literal string search across project files.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('isbinaryfile', () => ({
|
||||
isBinaryFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import { isBinaryFile } from 'isbinaryfile';
|
||||
|
||||
import { FileSearchService } from '@main/services/editor/FileSearchService';
|
||||
|
||||
const PROJECT_ROOT = '/test/project';
|
||||
|
||||
describe('FileSearchService', () => {
|
||||
let service: FileSearchService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
service = new FileSearchService();
|
||||
});
|
||||
|
||||
function mockFileSystem(files: Record<string, string>) {
|
||||
const entries = Object.keys(files).map((filePath) => {
|
||||
const name = path.basename(filePath);
|
||||
return { name, isFile: () => true, isDirectory: () => false };
|
||||
});
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue(entries as never);
|
||||
vi.mocked(isBinaryFile).mockResolvedValue(false);
|
||||
|
||||
vi.mocked(fs.stat).mockImplementation(async (filePath: unknown) => {
|
||||
const p = String(filePath);
|
||||
const content = files[p];
|
||||
if (content === undefined) throw new Error('ENOENT');
|
||||
return { size: content.length } as never;
|
||||
});
|
||||
|
||||
vi.mocked(fs.readFile).mockImplementation(async (filePath: unknown) => {
|
||||
const p = String(filePath);
|
||||
const content = files[p];
|
||||
if (content === undefined) throw new Error('ENOENT');
|
||||
return content as never;
|
||||
});
|
||||
}
|
||||
|
||||
it('finds matches in files', async () => {
|
||||
const files = {
|
||||
[`${PROJECT_ROOT}/hello.ts`]: 'const foo = "hello";\nconst bar = "world";\n',
|
||||
[`${PROJECT_ROOT}/world.ts`]: 'export const baz = "hello world";\n',
|
||||
};
|
||||
mockFileSystem(files);
|
||||
|
||||
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'hello' });
|
||||
|
||||
expect(result.totalMatches).toBeGreaterThanOrEqual(1);
|
||||
expect(result.results.length).toBeGreaterThanOrEqual(1);
|
||||
const match = result.results[0].matches[0];
|
||||
expect(match.line).toBe(1);
|
||||
expect(match.lineContent).toContain('hello');
|
||||
});
|
||||
|
||||
it('returns empty results for empty query', async () => {
|
||||
const result = await service.searchInFiles(PROJECT_ROOT, { query: '' });
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.totalMatches).toBe(0);
|
||||
});
|
||||
|
||||
it('supports case-sensitive search', async () => {
|
||||
const files = {
|
||||
[`${PROJECT_ROOT}/test.ts`]: 'Hello World\nhello world\n',
|
||||
};
|
||||
mockFileSystem(files);
|
||||
|
||||
const caseInsensitive = await service.searchInFiles(PROJECT_ROOT, { query: 'Hello' });
|
||||
expect(caseInsensitive.totalMatches).toBe(2); // both lines match
|
||||
|
||||
const caseSensitive = await service.searchInFiles(PROJECT_ROOT, {
|
||||
query: 'Hello',
|
||||
caseSensitive: true,
|
||||
});
|
||||
expect(caseSensitive.totalMatches).toBe(1); // only first line
|
||||
});
|
||||
|
||||
it('respects maxMatches limit', async () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `match line ${i}`).join('\n');
|
||||
const files = {
|
||||
[`${PROJECT_ROOT}/many.ts`]: lines,
|
||||
};
|
||||
mockFileSystem(files);
|
||||
|
||||
const result = await service.searchInFiles(PROJECT_ROOT, {
|
||||
query: 'match',
|
||||
maxMatches: 5,
|
||||
});
|
||||
|
||||
expect(result.totalMatches).toBeLessThanOrEqual(5);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('skips binary files', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'binary.bin', isFile: () => true, isDirectory: () => false },
|
||||
] as never);
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as never);
|
||||
vi.mocked(isBinaryFile).mockResolvedValue(true);
|
||||
|
||||
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'test' });
|
||||
expect(result.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips files larger than 1MB', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'large.ts', isFile: () => true, isDirectory: () => false },
|
||||
] as never);
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: 2 * 1024 * 1024 } as never);
|
||||
|
||||
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'test' });
|
||||
expect(result.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('respects AbortController cancellation', async () => {
|
||||
const files = {
|
||||
[`${PROJECT_ROOT}/file.ts`]: 'hello world\n',
|
||||
};
|
||||
mockFileSystem(files);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort(); // Already aborted
|
||||
|
||||
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'hello' }, controller.signal);
|
||||
// Should return empty or partial results since aborted
|
||||
expect(result.totalMatches).toBe(0);
|
||||
});
|
||||
|
||||
it('finds multiple matches in same line', async () => {
|
||||
const files = {
|
||||
[`${PROJECT_ROOT}/multi.ts`]: 'foo foo foo\n',
|
||||
};
|
||||
mockFileSystem(files);
|
||||
|
||||
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'foo' });
|
||||
expect(result.totalMatches).toBe(3);
|
||||
expect(result.results[0].matches).toHaveLength(3);
|
||||
expect(result.results[0].matches[0].column).toBe(0);
|
||||
expect(result.results[0].matches[1].column).toBe(4);
|
||||
expect(result.results[0].matches[2].column).toBe(8);
|
||||
});
|
||||
});
|
||||
217
test/main/services/editor/GitStatusService.test.ts
Normal file
217
test/main/services/editor/GitStatusService.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Tests for GitStatusService — caching, error handling, status mapping.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock simple-git
|
||||
const mockStatus = vi.fn();
|
||||
const mockRevparse = vi.fn();
|
||||
const mockEnv = vi.fn();
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn(() => {
|
||||
const git = {
|
||||
status: mockStatus,
|
||||
revparse: mockRevparse,
|
||||
env: mockEnv,
|
||||
};
|
||||
mockEnv.mockReturnValue(git);
|
||||
return git;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { simpleGit } from 'simple-git';
|
||||
|
||||
import {
|
||||
GitStatusService,
|
||||
mapStatusResult,
|
||||
} from '../../../../src/main/services/editor/GitStatusService';
|
||||
|
||||
import type { StatusResult } from 'simple-git';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
|
||||
return {
|
||||
not_added: [],
|
||||
conflicted: [],
|
||||
created: [],
|
||||
deleted: [],
|
||||
ignored: [],
|
||||
modified: [],
|
||||
renamed: [],
|
||||
staged: [],
|
||||
files: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
current: 'main',
|
||||
tracking: 'origin/main',
|
||||
detached: false,
|
||||
isClean: () => true,
|
||||
...overrides,
|
||||
} as StatusResult;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('GitStatusService', () => {
|
||||
let service: GitStatusService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
service = new GitStatusService();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('initializes simple-git with project root and GIT_OPTIONAL_LOCKS=0', () => {
|
||||
service.init('/Users/test/project');
|
||||
|
||||
expect(vi.mocked(simpleGit)).toHaveBeenCalledWith({
|
||||
baseDir: '/Users/test/project',
|
||||
timeout: { block: 10_000 },
|
||||
});
|
||||
expect(mockEnv).toHaveBeenCalledWith('GIT_OPTIONAL_LOCKS', '0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns empty non-repo result when not initialized', async () => {
|
||||
const result = await service.getStatus();
|
||||
|
||||
expect(result).toEqual({ files: [], isGitRepo: false, branch: null });
|
||||
});
|
||||
|
||||
it('returns isGitRepo: false for non-git directories', async () => {
|
||||
mockRevparse.mockRejectedValue(new Error('not a git repo'));
|
||||
|
||||
service.init('/Users/test/not-a-repo');
|
||||
const result = await service.getStatus();
|
||||
|
||||
expect(result.isGitRepo).toBe(false);
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.branch).toBeNull();
|
||||
});
|
||||
|
||||
it('returns file statuses for a git repo', async () => {
|
||||
mockRevparse.mockResolvedValue('true');
|
||||
mockStatus.mockResolvedValue(
|
||||
createMockStatusResult({
|
||||
modified: ['src/index.ts'],
|
||||
not_added: ['new-file.txt'],
|
||||
deleted: ['old.ts'],
|
||||
current: 'feature-branch',
|
||||
})
|
||||
);
|
||||
|
||||
service.init('/Users/test/project');
|
||||
const result = await service.getStatus();
|
||||
|
||||
expect(result.isGitRepo).toBe(true);
|
||||
expect(result.branch).toBe('feature-branch');
|
||||
expect(result.files).toContainEqual({ path: 'src/index.ts', status: 'modified' });
|
||||
expect(result.files).toContainEqual({ path: 'new-file.txt', status: 'untracked' });
|
||||
expect(result.files).toContainEqual({ path: 'old.ts', status: 'deleted' });
|
||||
});
|
||||
|
||||
it('caches results within TTL (5s)', async () => {
|
||||
mockRevparse.mockResolvedValue('true');
|
||||
mockStatus.mockResolvedValue(createMockStatusResult({ modified: ['a.ts'] }));
|
||||
|
||||
service.init('/Users/test/project');
|
||||
|
||||
// First call → hits git
|
||||
await service.getStatus();
|
||||
expect(mockStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call within TTL → cached
|
||||
await service.getStatus();
|
||||
expect(mockStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidateCache forces re-fetch', async () => {
|
||||
mockRevparse.mockResolvedValue('true');
|
||||
mockStatus.mockResolvedValue(createMockStatusResult({ modified: ['a.ts'] }));
|
||||
|
||||
service.init('/Users/test/project');
|
||||
|
||||
await service.getStatus();
|
||||
expect(mockStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.invalidateCache();
|
||||
await service.getStatus();
|
||||
expect(mockStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns empty result on git error (graceful degradation)', async () => {
|
||||
mockRevparse.mockResolvedValue('true');
|
||||
mockStatus.mockRejectedValue(new Error('git timeout'));
|
||||
|
||||
service.init('/Users/test/project');
|
||||
const result = await service.getStatus();
|
||||
|
||||
expect(result).toEqual({ files: [], isGitRepo: false, branch: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('resets all internal state', async () => {
|
||||
mockRevparse.mockResolvedValue('true');
|
||||
mockStatus.mockResolvedValue(createMockStatusResult());
|
||||
|
||||
service.init('/Users/test/project');
|
||||
await service.getStatus();
|
||||
|
||||
service.destroy();
|
||||
|
||||
// After destroy, should return empty result (no git instance)
|
||||
const result = await service.getStatus();
|
||||
expect(result).toEqual({ files: [], isGitRepo: false, branch: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapStatusResult', () => {
|
||||
it('maps all status categories', () => {
|
||||
const statusResult = createMockStatusResult({
|
||||
modified: ['a.ts'],
|
||||
not_added: ['b.ts'],
|
||||
staged: ['c.ts'],
|
||||
deleted: ['d.ts'],
|
||||
conflicted: ['e.ts'],
|
||||
renamed: [{ from: 'old.ts', to: 'new.ts' }] as StatusResult['renamed'],
|
||||
});
|
||||
|
||||
const files = mapStatusResult(statusResult);
|
||||
|
||||
expect(files).toContainEqual({ path: 'a.ts', status: 'modified' });
|
||||
expect(files).toContainEqual({ path: 'b.ts', status: 'untracked' });
|
||||
expect(files).toContainEqual({ path: 'c.ts', status: 'staged' });
|
||||
expect(files).toContainEqual({ path: 'd.ts', status: 'deleted' });
|
||||
expect(files).toContainEqual({ path: 'e.ts', status: 'conflict' });
|
||||
expect(files).toContainEqual({
|
||||
path: 'new.ts',
|
||||
status: 'renamed',
|
||||
renamedFrom: 'old.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array for clean repo', () => {
|
||||
const statusResult = createMockStatusResult();
|
||||
const files = mapStatusResult(statusResult);
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
});
|
||||
687
test/main/services/editor/ProjectFileService.test.ts
Normal file
687
test/main/services/editor/ProjectFileService.test.ts
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
/**
|
||||
* Tests for ProjectFileService — path security, binary detection, size limits.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock fs/promises before importing the service
|
||||
vi.mock('fs/promises', () => ({
|
||||
lstat: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
access: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
cp: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/atomicWrite', () => ({
|
||||
atomicWriteAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('isbinaryfile', () => ({
|
||||
isBinaryFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
shell: {
|
||||
trashItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { shell } from 'electron';
|
||||
import * as fs from 'fs/promises';
|
||||
import { isBinaryFile } from 'isbinaryfile';
|
||||
|
||||
import { atomicWriteAsync } from '../../../../src/main/utils/atomicWrite';
|
||||
import { ProjectFileService } from '../../../../src/main/services/editor/ProjectFileService';
|
||||
|
||||
// =============================================================================
|
||||
// Setup
|
||||
// =============================================================================
|
||||
|
||||
const PROJECT_ROOT = '/Users/test/my-project';
|
||||
let service: ProjectFileService;
|
||||
|
||||
const mockLstat = vi.mocked(fs.lstat);
|
||||
const mockStat = vi.mocked(fs.stat);
|
||||
const mockReaddir = vi.mocked(fs.readdir);
|
||||
const mockReadFile = vi.mocked(fs.readFile);
|
||||
const mockRealpath = vi.mocked(fs.realpath);
|
||||
const mockIsBinary = vi.mocked(isBinaryFile);
|
||||
const mockRename = vi.mocked(fs.rename);
|
||||
const mockCp = vi.mocked(fs.cp);
|
||||
const mockRm = vi.mocked(fs.rm);
|
||||
|
||||
function createStats(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): ReturnType<typeof fs.lstat> {
|
||||
return {
|
||||
isFile: () => overrides.isFile ?? true,
|
||||
isDirectory: () => overrides.isDirectory ?? false,
|
||||
isSymbolicLink: () => overrides.isSymbolicLink ?? false,
|
||||
size: overrides.size ?? 1024,
|
||||
mtimeMs: overrides.mtimeMs ?? Date.now(),
|
||||
} as Awaited<ReturnType<typeof fs.lstat>>;
|
||||
}
|
||||
|
||||
function createDirent(
|
||||
name: string,
|
||||
type: 'file' | 'directory' | 'symlink'
|
||||
): {
|
||||
name: string;
|
||||
isFile: () => boolean;
|
||||
isDirectory: () => boolean;
|
||||
isSymbolicLink: () => boolean;
|
||||
} {
|
||||
return {
|
||||
name,
|
||||
isFile: () => type === 'file',
|
||||
isDirectory: () => type === 'directory',
|
||||
isSymbolicLink: () => type === 'symlink',
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
service = new ProjectFileService();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// readDir
|
||||
// =============================================================================
|
||||
|
||||
describe('ProjectFileService.readDir', () => {
|
||||
it('returns sorted directory listing (dirs first, then alpha)', async () => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
mockReaddir.mockResolvedValue([
|
||||
createDirent('zebra.ts', 'file'),
|
||||
createDirent('src', 'directory'),
|
||||
createDirent('alpha.ts', 'file'),
|
||||
createDirent('docs', 'directory'),
|
||||
] as never);
|
||||
mockStat.mockResolvedValue(createStats({ size: 512 }));
|
||||
|
||||
const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT);
|
||||
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(result.entries.map((e) => e.name)).toEqual(['docs', 'src', 'alpha.ts', 'zebra.ts']);
|
||||
expect(result.entries[0].type).toBe('directory');
|
||||
expect(result.entries[2].type).toBe('file');
|
||||
});
|
||||
|
||||
it('filters out ignored directories (node_modules, .git, etc.)', async () => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
mockReaddir.mockResolvedValue([
|
||||
createDirent('node_modules', 'directory'),
|
||||
createDirent('.git', 'directory'),
|
||||
createDirent('src', 'directory'),
|
||||
createDirent('.next', 'directory'),
|
||||
] as never);
|
||||
|
||||
const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT);
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].name).toBe('src');
|
||||
});
|
||||
|
||||
it('filters out ignored files (.DS_Store, Thumbs.db)', async () => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
mockReaddir.mockResolvedValue([
|
||||
createDirent('.DS_Store', 'file'),
|
||||
createDirent('Thumbs.db', 'file'),
|
||||
createDirent('index.ts', 'file'),
|
||||
] as never);
|
||||
mockStat.mockResolvedValue(createStats({ size: 100 }));
|
||||
|
||||
const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT);
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].name).toBe('index.ts');
|
||||
});
|
||||
|
||||
it('marks sensitive files with isSensitive flag', async () => {
|
||||
const projectWithEnv = PROJECT_ROOT;
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
mockReaddir.mockResolvedValue([
|
||||
createDirent('.env', 'file'),
|
||||
createDirent('.env.local', 'file'),
|
||||
createDirent('index.ts', 'file'),
|
||||
] as never);
|
||||
mockStat.mockResolvedValue(createStats({ size: 100 }));
|
||||
|
||||
const result = await service.readDir(projectWithEnv, projectWithEnv);
|
||||
|
||||
const envEntry = result.entries.find((e) => e.name === '.env');
|
||||
const envLocalEntry = result.entries.find((e) => e.name === '.env.local');
|
||||
const indexEntry = result.entries.find((e) => e.name === 'index.ts');
|
||||
|
||||
expect(envEntry?.isSensitive).toBe(true);
|
||||
expect(envLocalEntry?.isSensitive).toBe(true);
|
||||
expect(indexEntry?.isSensitive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects paths outside project root (SEC-1)', async () => {
|
||||
await expect(service.readDir(PROJECT_ROOT, '/etc/passwd')).rejects.toThrow(
|
||||
'Directory is outside project root'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects path traversal via ../ (SEC-1)', async () => {
|
||||
const traversalPath = path.join(PROJECT_ROOT, '..', '..', 'etc');
|
||||
await expect(service.readDir(PROJECT_ROOT, traversalPath)).rejects.toThrow(
|
||||
'Directory is outside project root'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-directory paths', async () => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: false, isFile: true }));
|
||||
|
||||
await expect(service.readDir(PROJECT_ROOT, PROJECT_ROOT + '/file.txt')).rejects.toThrow(
|
||||
'Not a directory'
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates at maxEntries', async () => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
const dirents = Array.from({ length: 10 }, (_, i) => createDirent(`file${i}.ts`, 'file'));
|
||||
mockReaddir.mockResolvedValue(dirents as never);
|
||||
mockStat.mockResolvedValue(createStats({ size: 100 }));
|
||||
|
||||
const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT, 3);
|
||||
|
||||
expect(result.entries).toHaveLength(3);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('silently skips symlinks that escape project root (SEC-2)', async () => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
mockReaddir.mockResolvedValue([
|
||||
createDirent('safe-link', 'symlink'),
|
||||
createDirent('escape-link', 'symlink'),
|
||||
createDirent('normal.ts', 'file'),
|
||||
] as never);
|
||||
|
||||
mockRealpath.mockImplementation(async (p) => {
|
||||
const name = path.basename(String(p));
|
||||
if (name === 'safe-link') return PROJECT_ROOT + '/actual-dir';
|
||||
return '/etc/shadow'; // escapes project
|
||||
});
|
||||
|
||||
mockStat.mockResolvedValue(createStats({ size: 100, isDirectory: true, isFile: false }));
|
||||
|
||||
const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT);
|
||||
|
||||
const names = result.entries.map((e) => e.name);
|
||||
expect(names).toContain('safe-link');
|
||||
expect(names).toContain('normal.ts');
|
||||
expect(names).not.toContain('escape-link');
|
||||
});
|
||||
|
||||
it('silently skips broken symlinks', async () => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
mockReaddir.mockResolvedValue([
|
||||
createDirent('broken-link', 'symlink'),
|
||||
createDirent('normal.ts', 'file'),
|
||||
] as never);
|
||||
|
||||
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
||||
mockStat.mockResolvedValue(createStats({ size: 100 }));
|
||||
|
||||
const result = await service.readDir(PROJECT_ROOT, PROJECT_ROOT);
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].name).toBe('normal.ts');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// readFile
|
||||
// =============================================================================
|
||||
|
||||
describe('ProjectFileService.readFile', () => {
|
||||
it('returns file content with metadata', async () => {
|
||||
const filePath = PROJECT_ROOT + '/src/index.ts';
|
||||
const content = 'export const hello = "world";';
|
||||
const now = Date.now();
|
||||
|
||||
mockLstat.mockResolvedValue(createStats({ size: content.length, mtimeMs: now }));
|
||||
mockIsBinary.mockResolvedValue(false);
|
||||
mockReadFile.mockResolvedValue(content);
|
||||
mockRealpath.mockResolvedValue(filePath);
|
||||
|
||||
const result = await service.readFile(PROJECT_ROOT, filePath);
|
||||
|
||||
expect(result.content).toBe(content);
|
||||
expect(result.size).toBe(content.length);
|
||||
expect(result.mtimeMs).toBe(now);
|
||||
expect(result.isBinary).toBe(false);
|
||||
expect(result.encoding).toBe('utf-8');
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('returns binary indicator for binary files', async () => {
|
||||
const filePath = PROJECT_ROOT + '/image.png';
|
||||
|
||||
mockLstat.mockResolvedValue(createStats({ size: 4096, mtimeMs: Date.now() }));
|
||||
mockIsBinary.mockResolvedValue(true);
|
||||
|
||||
const result = await service.readFile(PROJECT_ROOT, filePath);
|
||||
|
||||
expect(result.isBinary).toBe(true);
|
||||
expect(result.content).toBe('');
|
||||
expect(result.encoding).toBe('binary');
|
||||
});
|
||||
|
||||
it('rejects files larger than 5MB preview limit', async () => {
|
||||
const filePath = PROJECT_ROOT + '/huge.log';
|
||||
const hugeSize = 6 * 1024 * 1024;
|
||||
|
||||
mockLstat.mockResolvedValue(createStats({ size: hugeSize }));
|
||||
|
||||
await expect(service.readFile(PROJECT_ROOT, filePath)).rejects.toThrow('File too large');
|
||||
});
|
||||
|
||||
it('returns preview (100 lines) for files between 2-5MB', async () => {
|
||||
const filePath = PROJECT_ROOT + '/large.json';
|
||||
const fileSize = 3 * 1024 * 1024;
|
||||
const lines = Array.from({ length: 200 }, (_, i) => `line ${i}`);
|
||||
const fullContent = lines.join('\n');
|
||||
|
||||
mockLstat.mockResolvedValue(createStats({ size: fileSize, mtimeMs: Date.now() }));
|
||||
mockIsBinary.mockResolvedValue(false);
|
||||
mockReadFile.mockResolvedValue(fullContent);
|
||||
mockRealpath.mockResolvedValue(filePath);
|
||||
|
||||
const result = await service.readFile(PROJECT_ROOT, filePath);
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.content.split('\n')).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('rejects sensitive file paths (.env, .ssh)', async () => {
|
||||
const envPath = PROJECT_ROOT + '/.env';
|
||||
await expect(service.readFile(PROJECT_ROOT, envPath)).rejects.toThrow(
|
||||
'Access to sensitive files is not allowed'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects paths outside project root', async () => {
|
||||
await expect(service.readFile(PROJECT_ROOT, '/etc/passwd')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects device paths (SEC-4)', async () => {
|
||||
const devPath = '/dev/zero';
|
||||
// /dev/zero is outside project root, so it should throw before device check
|
||||
await expect(service.readFile(PROJECT_ROOT, devPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-regular files (directories, etc.)', async () => {
|
||||
const dirPath = PROJECT_ROOT + '/src';
|
||||
mockLstat.mockResolvedValue(createStats({ isFile: false, isDirectory: true }));
|
||||
|
||||
await expect(service.readFile(PROJECT_ROOT, dirPath)).rejects.toThrow('Not a regular file');
|
||||
});
|
||||
|
||||
it('detects TOCTOU — rejects if path changed during read (SEC-3)', async () => {
|
||||
const filePath = PROJECT_ROOT + '/safe.ts';
|
||||
|
||||
mockLstat.mockResolvedValue(createStats({ size: 100, mtimeMs: Date.now() }));
|
||||
mockIsBinary.mockResolvedValue(false);
|
||||
mockReadFile.mockResolvedValue('content');
|
||||
// realpath returns a path OUTSIDE project root (symlink swapped)
|
||||
mockRealpath.mockResolvedValue('/etc/shadow');
|
||||
|
||||
await expect(service.readFile(PROJECT_ROOT, filePath)).rejects.toThrow(
|
||||
'Path changed during read (TOCTOU)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// writeFile
|
||||
// =============================================================================
|
||||
|
||||
const mockAtomicWrite = vi.mocked(atomicWriteAsync);
|
||||
|
||||
describe('ProjectFileService.writeFile', () => {
|
||||
const CONTENT = 'export const hello = "world";';
|
||||
|
||||
beforeEach(() => {
|
||||
mockAtomicWrite.mockResolvedValue(undefined);
|
||||
mockStat.mockResolvedValue(createStats({ size: CONTENT.length, mtimeMs: Date.now() }));
|
||||
});
|
||||
|
||||
it('writes file via atomic write and returns stats', async () => {
|
||||
const filePath = PROJECT_ROOT + '/src/index.ts';
|
||||
const now = Date.now();
|
||||
mockStat.mockResolvedValue(createStats({ size: 28, mtimeMs: now }));
|
||||
|
||||
const result = await service.writeFile(PROJECT_ROOT, filePath, CONTENT);
|
||||
|
||||
expect(mockAtomicWrite).toHaveBeenCalledWith(path.resolve(filePath), CONTENT);
|
||||
expect(result.size).toBe(28);
|
||||
expect(result.mtimeMs).toBe(now);
|
||||
});
|
||||
|
||||
it('rejects paths outside project root (SEC-14)', async () => {
|
||||
await expect(service.writeFile(PROJECT_ROOT, '/etc/passwd', 'malicious')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects path traversal via ../ (SEC-1)', async () => {
|
||||
const traversalPath = path.join(PROJECT_ROOT, '..', '..', 'etc', 'passwd');
|
||||
await expect(service.writeFile(PROJECT_ROOT, traversalPath, 'malicious')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects .git/ internal paths (SEC-12)', async () => {
|
||||
const gitPath = PROJECT_ROOT + '/.git/config';
|
||||
await expect(service.writeFile(PROJECT_ROOT, gitPath, 'malicious')).rejects.toThrow(
|
||||
'Cannot write to .git/ directory'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects sensitive file paths (.env)', async () => {
|
||||
const envPath = PROJECT_ROOT + '/.env';
|
||||
await expect(service.writeFile(PROJECT_ROOT, envPath, 'SECRET=key')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects content larger than 2MB', async () => {
|
||||
const filePath = PROJECT_ROOT + '/src/large.ts';
|
||||
const largeContent = 'a'.repeat(3 * 1024 * 1024);
|
||||
|
||||
await expect(service.writeFile(PROJECT_ROOT, filePath, largeContent)).rejects.toThrow(
|
||||
'Content too large'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects device paths (SEC-4)', async () => {
|
||||
const devPath = '/dev/null';
|
||||
await expect(service.writeFile(PROJECT_ROOT, devPath, 'data')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes through atomic write errors', async () => {
|
||||
const filePath = PROJECT_ROOT + '/src/index.ts';
|
||||
mockAtomicWrite.mockRejectedValue(new Error('Disk full'));
|
||||
|
||||
await expect(service.writeFile(PROJECT_ROOT, filePath, CONTENT)).rejects.toThrow('Disk full');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createFile
|
||||
// =============================================================================
|
||||
|
||||
const mockWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockAccess = vi.mocked(fs.access);
|
||||
const mockMkdir = vi.mocked(fs.mkdir);
|
||||
const mockTrashItem = vi.mocked(shell.trashItem);
|
||||
|
||||
describe('ProjectFileService.createFile', () => {
|
||||
beforeEach(() => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
mockAccess.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockStat.mockResolvedValue(createStats({ size: 0, mtimeMs: 1234567890 }));
|
||||
});
|
||||
|
||||
it('creates an empty file and returns stats', async () => {
|
||||
const parentDir = PROJECT_ROOT + '/src';
|
||||
const result = await service.createFile(PROJECT_ROOT, parentDir, 'new-file.ts');
|
||||
|
||||
expect(result.filePath).toBe(path.join(PROJECT_ROOT, 'src', 'new-file.ts'));
|
||||
expect(result.mtimeMs).toBe(1234567890);
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
path.join(PROJECT_ROOT, 'src', 'new-file.ts'),
|
||||
'',
|
||||
'utf8'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid file name (empty)', async () => {
|
||||
await expect(service.createFile(PROJECT_ROOT, PROJECT_ROOT, '')).rejects.toThrow(
|
||||
'Name is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid file name (..)', async () => {
|
||||
await expect(service.createFile(PROJECT_ROOT, PROJECT_ROOT, '..')).rejects.toThrow(
|
||||
'Invalid name'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects paths outside project root', async () => {
|
||||
await expect(service.createFile(PROJECT_ROOT, '/etc', 'file.ts')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects if file already exists', async () => {
|
||||
mockAccess.mockResolvedValue(undefined); // File exists
|
||||
|
||||
await expect(
|
||||
service.createFile(PROJECT_ROOT, PROJECT_ROOT + '/src', 'existing.ts')
|
||||
).rejects.toThrow('File already exists');
|
||||
});
|
||||
|
||||
it('blocks .git/ internal paths (SEC-12)', async () => {
|
||||
await expect(
|
||||
service.createFile(PROJECT_ROOT, PROJECT_ROOT + '/.git', 'config')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createDir
|
||||
// =============================================================================
|
||||
|
||||
describe('ProjectFileService.createDir', () => {
|
||||
beforeEach(() => {
|
||||
mockLstat.mockResolvedValue(createStats({ isDirectory: true, isFile: false }));
|
||||
mockAccess.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('creates a directory', async () => {
|
||||
const parentDir = PROJECT_ROOT + '/src';
|
||||
const result = await service.createDir(PROJECT_ROOT, parentDir, 'new-dir');
|
||||
|
||||
expect(result.dirPath).toBe(path.join(PROJECT_ROOT, 'src', 'new-dir'));
|
||||
expect(mockMkdir).toHaveBeenCalledWith(path.join(PROJECT_ROOT, 'src', 'new-dir'));
|
||||
});
|
||||
|
||||
it('rejects invalid dir name', async () => {
|
||||
await expect(service.createDir(PROJECT_ROOT, PROJECT_ROOT, '..')).rejects.toThrow(
|
||||
'Invalid name'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects paths outside project root', async () => {
|
||||
await expect(service.createDir(PROJECT_ROOT, '/tmp', 'dir')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects if directory already exists', async () => {
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.createDir(PROJECT_ROOT, PROJECT_ROOT + '/src', 'existing-dir')
|
||||
).rejects.toThrow('Directory already exists');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// deleteFile
|
||||
// =============================================================================
|
||||
|
||||
describe('ProjectFileService.deleteFile', () => {
|
||||
beforeEach(() => {
|
||||
mockLstat.mockResolvedValue(createStats({ isFile: true }));
|
||||
mockTrashItem.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('moves file to trash', async () => {
|
||||
const filePath = PROJECT_ROOT + '/src/old-file.ts';
|
||||
const result = await service.deleteFile(PROJECT_ROOT, filePath);
|
||||
|
||||
expect(result.deletedPath).toBe(path.resolve(filePath));
|
||||
expect(mockTrashItem).toHaveBeenCalledWith(path.resolve(filePath));
|
||||
});
|
||||
|
||||
it('rejects paths outside project root', async () => {
|
||||
await expect(service.deleteFile(PROJECT_ROOT, '/etc/passwd')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('blocks .git/ internal paths (SEC-12)', async () => {
|
||||
await expect(service.deleteFile(PROJECT_ROOT, PROJECT_ROOT + '/.git/config')).rejects.toThrow(
|
||||
'Cannot delete files in .git/ directory'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects sensitive file paths', async () => {
|
||||
await expect(service.deleteFile(PROJECT_ROOT, PROJECT_ROOT + '/.env')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// moveFile
|
||||
// =============================================================================
|
||||
|
||||
describe('ProjectFileService.moveFile', () => {
|
||||
const SRC_DIR = PROJECT_ROOT + '/src';
|
||||
const DEST_DIR = PROJECT_ROOT + '/lib';
|
||||
|
||||
beforeEach(() => {
|
||||
mockRename.mockResolvedValue(undefined);
|
||||
mockAccess.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
});
|
||||
|
||||
it('moves a file to a new directory (happy path)', async () => {
|
||||
const sourcePath = SRC_DIR + '/index.ts';
|
||||
mockLstat
|
||||
.mockResolvedValueOnce(createStats({ isFile: true })) // source exists
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })); // dest is dir
|
||||
|
||||
const result = await service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR);
|
||||
|
||||
expect(result.newPath).toBe(path.join(DEST_DIR, 'index.ts'));
|
||||
expect(mockRename).toHaveBeenCalledWith(
|
||||
path.resolve(sourcePath),
|
||||
path.join(DEST_DIR, 'index.ts')
|
||||
);
|
||||
});
|
||||
|
||||
it('moves a directory to a new directory (happy path)', async () => {
|
||||
const sourceDir = PROJECT_ROOT + '/utils';
|
||||
mockLstat
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) // source
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })); // dest
|
||||
|
||||
const result = await service.moveFile(PROJECT_ROOT, sourceDir, DEST_DIR);
|
||||
|
||||
expect(result.newPath).toBe(path.join(DEST_DIR, 'utils'));
|
||||
expect(mockRename).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects parent → child move', async () => {
|
||||
const sourceDir = SRC_DIR;
|
||||
const childDir = SRC_DIR + '/nested';
|
||||
mockLstat
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false }))
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false }));
|
||||
|
||||
await expect(service.moveFile(PROJECT_ROOT, sourceDir, childDir)).rejects.toThrow(
|
||||
'Cannot move a directory into itself'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when destination file already exists', async () => {
|
||||
const sourcePath = SRC_DIR + '/index.ts';
|
||||
mockLstat
|
||||
.mockResolvedValueOnce(createStats({ isFile: true }))
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false }));
|
||||
mockAccess.mockResolvedValue(undefined); // file exists at dest
|
||||
|
||||
await expect(service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR)).rejects.toThrow(
|
||||
'File already exists at destination'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects .git/ source paths (SEC-12)', async () => {
|
||||
const gitPath = PROJECT_ROOT + '/.git/hooks';
|
||||
|
||||
await expect(service.moveFile(PROJECT_ROOT, gitPath, DEST_DIR)).rejects.toThrow(
|
||||
'Cannot move files from .git/ directory'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects .git/ destination paths (SEC-12)', async () => {
|
||||
const sourcePath = SRC_DIR + '/index.ts';
|
||||
const gitDest = PROJECT_ROOT + '/.git';
|
||||
|
||||
await expect(service.moveFile(PROJECT_ROOT, sourcePath, gitDest)).rejects.toThrow(
|
||||
'Cannot move files into .git/ directory'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects paths outside project root', async () => {
|
||||
await expect(service.moveFile(PROJECT_ROOT, '/etc/passwd', DEST_DIR)).rejects.toThrow();
|
||||
await expect(service.moveFile(PROJECT_ROOT, SRC_DIR + '/index.ts', '/tmp')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('falls back to cp+rm on EXDEV error (cross-device)', async () => {
|
||||
const sourcePath = SRC_DIR + '/index.ts';
|
||||
mockLstat
|
||||
.mockResolvedValueOnce(createStats({ isFile: true })) // source exists
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) // dest is dir
|
||||
.mockResolvedValueOnce(createStats({ isFile: true })); // EXDEV fallback stat
|
||||
|
||||
const exdevError = Object.assign(new Error('EXDEV'), { code: 'EXDEV' });
|
||||
mockRename.mockRejectedValueOnce(exdevError);
|
||||
|
||||
const mockCopyFile = vi.mocked(fs.copyFile);
|
||||
mockCopyFile.mockResolvedValue(undefined);
|
||||
mockRm.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.moveFile(PROJECT_ROOT, sourcePath, DEST_DIR);
|
||||
|
||||
expect(result.newPath).toBe(path.join(DEST_DIR, 'index.ts'));
|
||||
expect(mockCopyFile).toHaveBeenCalled();
|
||||
expect(mockRm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to cp+rm for directories on EXDEV error', async () => {
|
||||
const sourceDir = PROJECT_ROOT + '/utils';
|
||||
mockLstat
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) // source
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })) // dest
|
||||
.mockResolvedValueOnce(createStats({ isDirectory: true, isFile: false })); // EXDEV fallback
|
||||
|
||||
const exdevError = Object.assign(new Error('EXDEV'), { code: 'EXDEV' });
|
||||
mockRename.mockRejectedValueOnce(exdevError);
|
||||
mockCp.mockResolvedValue(undefined);
|
||||
mockRm.mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.moveFile(PROJECT_ROOT, sourceDir, DEST_DIR);
|
||||
|
||||
expect(result.newPath).toBe(path.join(DEST_DIR, 'utils'));
|
||||
expect(mockCp).toHaveBeenCalledWith(path.resolve(sourceDir), path.join(DEST_DIR, 'utils'), {
|
||||
recursive: true,
|
||||
});
|
||||
expect(mockRm).toHaveBeenCalledWith(path.resolve(sourceDir), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
95
test/main/services/editor/conflictDetection.test.ts
Normal file
95
test/main/services/editor/conflictDetection.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Tests for conflictDetection — mtime comparison, deleted files, tolerance.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import { checkFileConflict } from '../../../../src/main/services/editor/conflictDetection';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function mockStat(mtimeMs: number): void {
|
||||
vi.mocked(fs.stat).mockResolvedValue({ mtimeMs } as Awaited<ReturnType<typeof fs.stat>>);
|
||||
}
|
||||
|
||||
function mockStatError(code: string): void {
|
||||
const err = new Error(`${code}: no such file`) as NodeJS.ErrnoException;
|
||||
err.code = code;
|
||||
vi.mocked(fs.stat).mockRejectedValue(err);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('checkFileConflict', () => {
|
||||
it('returns no conflict when mtime matches exactly', async () => {
|
||||
mockStat(1000);
|
||||
|
||||
const result = await checkFileConflict('/test/file.ts', 1000);
|
||||
|
||||
expect(result.hasConflict).toBe(false);
|
||||
expect(result.currentMtimeMs).toBe(1000);
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it('returns no conflict within 1ms tolerance', async () => {
|
||||
mockStat(1000.5);
|
||||
|
||||
const result = await checkFileConflict('/test/file.ts', 1000);
|
||||
|
||||
expect(result.hasConflict).toBe(false);
|
||||
});
|
||||
|
||||
it('detects conflict when mtime differs by more than 1ms', async () => {
|
||||
mockStat(2000);
|
||||
|
||||
const result = await checkFileConflict('/test/file.ts', 1000);
|
||||
|
||||
expect(result.hasConflict).toBe(true);
|
||||
expect(result.currentMtimeMs).toBe(2000);
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it('detects deleted file (ENOENT)', async () => {
|
||||
mockStatError('ENOENT');
|
||||
|
||||
const result = await checkFileConflict('/test/file.ts', 1000);
|
||||
|
||||
expect(result.hasConflict).toBe(true);
|
||||
expect(result.currentMtimeMs).toBe(0);
|
||||
expect(result.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('re-throws non-ENOENT errors', async () => {
|
||||
mockStatError('EPERM');
|
||||
|
||||
await expect(checkFileConflict('/test/file.ts', 1000)).rejects.toThrow('EPERM');
|
||||
});
|
||||
|
||||
it('handles mtime slightly earlier than baseline (e.g. clock drift)', async () => {
|
||||
mockStat(999);
|
||||
|
||||
const result = await checkFileConflict('/test/file.ts', 1000);
|
||||
|
||||
// |999 - 1000| = 1, which is <= 1ms tolerance
|
||||
expect(result.hasConflict).toBe(false);
|
||||
});
|
||||
|
||||
it('detects conflict for mtime 2ms earlier than baseline', async () => {
|
||||
mockStat(998);
|
||||
|
||||
const result = await checkFileConflict('/test/file.ts', 1000);
|
||||
|
||||
// |998 - 1000| = 2, which is > 1ms tolerance
|
||||
expect(result.hasConflict).toBe(true);
|
||||
});
|
||||
});
|
||||
153
test/main/utils/atomicWrite.test.ts
Normal file
153
test/main/utils/atomicWrite.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Tests for atomicWriteAsync — tmp + fsync + rename atomic write pattern.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
promises: {
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
open: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { atomicWriteAsync } from '../../../src/main/utils/atomicWrite';
|
||||
|
||||
// =============================================================================
|
||||
// Setup
|
||||
// =============================================================================
|
||||
|
||||
const mockMkdir = vi.mocked(fs.promises.mkdir);
|
||||
const mockWriteFile = vi.mocked(fs.promises.writeFile);
|
||||
const mockOpen = vi.mocked(fs.promises.open);
|
||||
const mockRename = vi.mocked(fs.promises.rename);
|
||||
const mockCopyFile = vi.mocked(fs.promises.copyFile);
|
||||
const mockUnlink = vi.mocked(fs.promises.unlink);
|
||||
|
||||
const TARGET_PATH = '/Users/test/project/src/index.ts';
|
||||
const TARGET_DIR = path.dirname(TARGET_PATH);
|
||||
const CONTENT = 'export const hello = "world";';
|
||||
|
||||
/** Extract the tmp path from writeFile calls */
|
||||
function getTmpPath(): string {
|
||||
const call = mockWriteFile.mock.calls[0];
|
||||
return String(call[0]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Default happy path
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockOpen.mockResolvedValue({
|
||||
sync: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as fs.promises.FileHandle);
|
||||
mockRename.mockResolvedValue(undefined);
|
||||
mockUnlink.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('atomicWriteAsync', () => {
|
||||
it('writes to tmp file in same directory then renames to target', async () => {
|
||||
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
||||
|
||||
// writeFile should be called with a tmp path in the same directory
|
||||
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
||||
const tmpPath = getTmpPath();
|
||||
expect(tmpPath).toMatch(new RegExp(`^${TARGET_DIR}/\\.tmp\\.[a-f0-9-]+$`));
|
||||
|
||||
// rename from tmp to target
|
||||
expect(mockRename).toHaveBeenCalledWith(tmpPath, TARGET_PATH);
|
||||
});
|
||||
|
||||
it('creates parent directories recursively', async () => {
|
||||
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
||||
|
||||
expect(mockMkdir).toHaveBeenCalledWith(TARGET_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('writes content with utf8 encoding', async () => {
|
||||
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(expect.any(String), CONTENT, 'utf8');
|
||||
});
|
||||
|
||||
it('calls fsync on tmp file before rename', async () => {
|
||||
const mockSync = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
mockOpen.mockResolvedValue({
|
||||
sync: mockSync,
|
||||
close: mockClose,
|
||||
} as unknown as fs.promises.FileHandle);
|
||||
|
||||
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
||||
|
||||
const tmpPath = getTmpPath();
|
||||
expect(mockOpen).toHaveBeenCalledWith(tmpPath, 'r+');
|
||||
expect(mockSync).toHaveBeenCalled();
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still renames even if fsync fails (best-effort)', async () => {
|
||||
mockOpen.mockRejectedValue(new Error('fsync not supported'));
|
||||
|
||||
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
||||
|
||||
expect(mockRename).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to copyFile + unlink on EXDEV error', async () => {
|
||||
const exdevError = Object.assign(new Error('Cross-device link'), { code: 'EXDEV' });
|
||||
mockRename.mockRejectedValue(exdevError);
|
||||
|
||||
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
||||
|
||||
const tmpPath = getTmpPath();
|
||||
expect(mockCopyFile).toHaveBeenCalledWith(tmpPath, TARGET_PATH);
|
||||
expect(mockUnlink).toHaveBeenCalledWith(tmpPath);
|
||||
});
|
||||
|
||||
it('still succeeds EXDEV fallback even if tmp cleanup fails', async () => {
|
||||
const exdevError = Object.assign(new Error('Cross-device link'), { code: 'EXDEV' });
|
||||
mockRename.mockRejectedValue(exdevError);
|
||||
mockUnlink.mockRejectedValue(new Error('permission denied'));
|
||||
|
||||
// Should not throw
|
||||
await atomicWriteAsync(TARGET_PATH, CONTENT);
|
||||
|
||||
expect(mockCopyFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-throws non-EXDEV rename errors and cleans tmp', async () => {
|
||||
const permError = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
|
||||
mockRename.mockRejectedValue(permError);
|
||||
|
||||
await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow('Permission denied');
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up tmp file on writeFile failure', async () => {
|
||||
mockWriteFile.mockRejectedValue(new Error('Disk full'));
|
||||
|
||||
await expect(atomicWriteAsync(TARGET_PATH, CONTENT)).rejects.toThrow('Disk full');
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates parent directories for deeply nested paths', async () => {
|
||||
const deepPath = '/Users/test/project/src/deep/nested/file.ts';
|
||||
await atomicWriteAsync(deepPath, CONTENT);
|
||||
|
||||
expect(mockMkdir).toHaveBeenCalledWith(path.dirname(deepPath), { recursive: true });
|
||||
});
|
||||
});
|
||||
229
test/renderer/components/team/editor/EditorSelectionMenu.test.ts
Normal file
229
test/renderer/components/team/editor/EditorSelectionMenu.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Unit tests for EditorSelectionMenu positioning logic
|
||||
* and buildSelectionAction helper.
|
||||
*
|
||||
* Since @testing-library/react is not available in this project,
|
||||
* we test the positioning logic and the real buildSelectionAction directly.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSelectionAction, getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
|
||||
|
||||
import type { EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSelectionAction (real import, not a copy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildSelectionAction', () => {
|
||||
const baseInfo: EditorSelectionInfo = {
|
||||
text: 'const x = 42;',
|
||||
filePath: '/project/src/main.ts',
|
||||
fromLine: 10,
|
||||
toLine: 10,
|
||||
screenRect: { top: 100, right: 200, bottom: 120 },
|
||||
};
|
||||
|
||||
it('builds sendMessage action with code fence', () => {
|
||||
const action = buildSelectionAction('sendMessage', baseInfo);
|
||||
|
||||
expect(action.type).toBe('sendMessage');
|
||||
expect(action.filePath).toBe('/project/src/main.ts');
|
||||
expect(action.fromLine).toBe(10);
|
||||
expect(action.toLine).toBe(10);
|
||||
expect(action.selectedText).toBe('const x = 42;');
|
||||
expect(action.formattedContext).toBe(
|
||||
'**main.ts** (line 10):\n```typescript\nconst x = 42;\n```'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds createTask action', () => {
|
||||
const action = buildSelectionAction('createTask', baseInfo);
|
||||
|
||||
expect(action.type).toBe('createTask');
|
||||
expect(action.formattedContext).toContain('```typescript');
|
||||
});
|
||||
|
||||
it('formats multi-line selection range', () => {
|
||||
const info = { ...baseInfo, fromLine: 5, toLine: 15 };
|
||||
const action = buildSelectionAction('sendMessage', info);
|
||||
|
||||
expect(action.formattedContext).toContain('lines 5-15');
|
||||
});
|
||||
|
||||
it('detects language from file extension', () => {
|
||||
const pyInfo = { ...baseInfo, filePath: '/project/script.py' };
|
||||
const action = buildSelectionAction('sendMessage', pyInfo);
|
||||
|
||||
expect(action.formattedContext).toContain('```python');
|
||||
expect(action.formattedContext).toContain('**script.py**');
|
||||
});
|
||||
|
||||
it('handles unknown file extensions gracefully', () => {
|
||||
const unknownInfo = { ...baseInfo, filePath: '/project/data.xyz' };
|
||||
const action = buildSelectionAction('sendMessage', unknownInfo);
|
||||
|
||||
// Empty language string → plain code block
|
||||
expect(action.formattedContext).toContain('```\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCodeFenceLanguage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getCodeFenceLanguage', () => {
|
||||
it('maps common extensions to lowercase code fence identifiers', () => {
|
||||
expect(getCodeFenceLanguage('app.ts')).toBe('typescript');
|
||||
expect(getCodeFenceLanguage('component.tsx')).toBe('tsx');
|
||||
expect(getCodeFenceLanguage('index.js')).toBe('javascript');
|
||||
expect(getCodeFenceLanguage('main.py')).toBe('python');
|
||||
expect(getCodeFenceLanguage('lib.rs')).toBe('rust');
|
||||
expect(getCodeFenceLanguage('main.go')).toBe('go');
|
||||
expect(getCodeFenceLanguage('style.css')).toBe('css');
|
||||
expect(getCodeFenceLanguage('page.html')).toBe('html');
|
||||
expect(getCodeFenceLanguage('config.yaml')).toBe('yaml');
|
||||
expect(getCodeFenceLanguage('config.yml')).toBe('yaml');
|
||||
expect(getCodeFenceLanguage('script.sh')).toBe('bash');
|
||||
});
|
||||
|
||||
it('returns empty string for unknown extensions', () => {
|
||||
expect(getCodeFenceLanguage('data.xyz')).toBe('');
|
||||
expect(getCodeFenceLanguage('file')).toBe('');
|
||||
});
|
||||
|
||||
it('is case-insensitive for extensions', () => {
|
||||
expect(getCodeFenceLanguage('App.TS')).toBe('typescript');
|
||||
expect(getCodeFenceLanguage('Main.PY')).toBe('python');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EditorSelectionInfo type shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('EditorSelectionInfo type', () => {
|
||||
it('has expected shape', () => {
|
||||
const info: EditorSelectionInfo = {
|
||||
text: 'hello',
|
||||
filePath: '/a/b.ts',
|
||||
fromLine: 1,
|
||||
toLine: 1,
|
||||
screenRect: { top: 0, right: 0, bottom: 0 },
|
||||
};
|
||||
|
||||
expect(info.text).toBe('hello');
|
||||
expect(info.screenRect).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menu positioning logic (mirrors EditorSelectionMenu.tsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Menu positioning logic', () => {
|
||||
const MENU_GAP = 8;
|
||||
const MENU_WIDTH = 68;
|
||||
const MENU_HEIGHT = 32;
|
||||
|
||||
function computeMenuPosition(
|
||||
info: EditorSelectionInfo,
|
||||
containerRect: { top: number; left: number; width: number; height: number }
|
||||
): { top: number; left: number } | null {
|
||||
// Check visibility
|
||||
const selBottomInContainer = info.screenRect.bottom - containerRect.top;
|
||||
const selTopInContainer = info.screenRect.top - containerRect.top;
|
||||
if (selBottomInContainer < 0 || selTopInContainer > containerRect.height) {
|
||||
return null; // hidden
|
||||
}
|
||||
|
||||
const rawTop = info.screenRect.top - containerRect.top;
|
||||
const rawLeft = info.screenRect.right - containerRect.left + MENU_GAP;
|
||||
|
||||
const top = Math.max(0, Math.min(rawTop, containerRect.height - MENU_HEIGHT));
|
||||
const left =
|
||||
rawLeft + MENU_WIDTH > containerRect.width
|
||||
? info.screenRect.right - containerRect.left - MENU_WIDTH - MENU_GAP
|
||||
: rawLeft;
|
||||
|
||||
return { top, left: Math.max(0, left) };
|
||||
}
|
||||
|
||||
it('positions menu to the right of selection', () => {
|
||||
const info: EditorSelectionInfo = {
|
||||
text: 'x',
|
||||
filePath: '/a.ts',
|
||||
fromLine: 1,
|
||||
toLine: 1,
|
||||
screenRect: { top: 100, right: 200, bottom: 120 },
|
||||
};
|
||||
const container = { top: 50, left: 50, width: 600, height: 400 };
|
||||
|
||||
const pos = computeMenuPosition(info, container);
|
||||
expect(pos).not.toBeNull();
|
||||
// top = 100 - 50 = 50
|
||||
expect(pos!.top).toBe(50);
|
||||
// left = 200 - 50 + 8 = 158
|
||||
expect(pos!.left).toBe(158);
|
||||
});
|
||||
|
||||
it('returns null when selection is above container', () => {
|
||||
const info: EditorSelectionInfo = {
|
||||
text: 'x',
|
||||
filePath: '/a.ts',
|
||||
fromLine: 1,
|
||||
toLine: 1,
|
||||
screenRect: { top: 10, right: 200, bottom: 30 },
|
||||
};
|
||||
const container = { top: 50, left: 50, width: 600, height: 400 };
|
||||
|
||||
expect(computeMenuPosition(info, container)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when selection is below container', () => {
|
||||
const info: EditorSelectionInfo = {
|
||||
text: 'x',
|
||||
filePath: '/a.ts',
|
||||
fromLine: 1,
|
||||
toLine: 1,
|
||||
screenRect: { top: 500, right: 200, bottom: 520 },
|
||||
};
|
||||
const container = { top: 50, left: 50, width: 600, height: 400 };
|
||||
|
||||
expect(computeMenuPosition(info, container)).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps top to prevent overflow below container', () => {
|
||||
const info: EditorSelectionInfo = {
|
||||
text: 'x',
|
||||
filePath: '/a.ts',
|
||||
fromLine: 1,
|
||||
toLine: 1,
|
||||
screenRect: { top: 430, right: 200, bottom: 445 },
|
||||
};
|
||||
const container = { top: 50, left: 50, width: 600, height: 400 };
|
||||
|
||||
const pos = computeMenuPosition(info, container);
|
||||
expect(pos).not.toBeNull();
|
||||
// rawTop = 430-50 = 380, max = 400-32 = 368 → clamped to 368
|
||||
expect(pos!.top).toBe(368);
|
||||
});
|
||||
|
||||
it('flips menu to left when it would overflow right', () => {
|
||||
const info: EditorSelectionInfo = {
|
||||
text: 'x',
|
||||
filePath: '/a.ts',
|
||||
fromLine: 1,
|
||||
toLine: 1,
|
||||
screenRect: { top: 100, right: 620, bottom: 120 },
|
||||
};
|
||||
const container = { top: 50, left: 50, width: 600, height: 400 };
|
||||
|
||||
const pos = computeMenuPosition(info, container);
|
||||
expect(pos).not.toBeNull();
|
||||
// rawLeft = 620-50+8 = 578, 578+68=646 > 600 → flip
|
||||
// flipped = 620-50-68-8 = 494
|
||||
expect(pos!.left).toBe(494);
|
||||
});
|
||||
});
|
||||
88
test/renderer/components/team/editor/fileIcons.test.ts
Normal file
88
test/renderer/components/team/editor/fileIcons.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Tests for fileIcons utility — extension-to-icon mapping.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getFileIcon } from '@renderer/components/team/editor/fileIcons';
|
||||
|
||||
describe('getFileIcon', () => {
|
||||
it('returns TypeScript icon for .ts files', () => {
|
||||
const info = getFileIcon('index.ts');
|
||||
expect(info.color).toBe('#3178c6');
|
||||
});
|
||||
|
||||
it('returns TypeScript icon for .tsx files', () => {
|
||||
const info = getFileIcon('App.tsx');
|
||||
expect(info.color).toBe('#3178c6');
|
||||
});
|
||||
|
||||
it('returns JavaScript icon for .js files', () => {
|
||||
const info = getFileIcon('app.js');
|
||||
expect(info.color).toBe('#f7df1e');
|
||||
});
|
||||
|
||||
it('returns JSON icon for .json files', () => {
|
||||
const info = getFileIcon('package.json');
|
||||
// package.json has special mapping
|
||||
expect(info.color).toBe('#cb3837');
|
||||
});
|
||||
|
||||
it('returns markdown icon for .md files', () => {
|
||||
const info = getFileIcon('README.md');
|
||||
expect(info.color).toBe('#519aba');
|
||||
});
|
||||
|
||||
it('returns Python icon for .py files', () => {
|
||||
const info = getFileIcon('main.py');
|
||||
expect(info.color).toBe('#3572a5');
|
||||
});
|
||||
|
||||
it('returns Rust icon for .rs files', () => {
|
||||
const info = getFileIcon('lib.rs');
|
||||
expect(info.color).toBe('#dea584');
|
||||
});
|
||||
|
||||
it('returns default icon for unknown extensions', () => {
|
||||
const info = getFileIcon('file.xyz123');
|
||||
expect(info.color).toBe('#89949f');
|
||||
});
|
||||
|
||||
it('returns default icon for files without extension', () => {
|
||||
const info = getFileIcon('Procfile');
|
||||
expect(info.color).toBe('#89949f');
|
||||
});
|
||||
|
||||
it('matches special filenames exactly', () => {
|
||||
const docker = getFileIcon('Dockerfile');
|
||||
expect(docker.color).toBe('#2496ed');
|
||||
|
||||
const gitignore = getFileIcon('.gitignore');
|
||||
expect(gitignore.color).toBe('#f05032');
|
||||
|
||||
const claudeMd = getFileIcon('CLAUDE.md');
|
||||
expect(claudeMd.color).toBe('#d97706');
|
||||
});
|
||||
|
||||
it('prefers filename match over extension match', () => {
|
||||
// tsconfig.json should match FILENAME_MAP, not generic .json
|
||||
const tsconfig = getFileIcon('tsconfig.json');
|
||||
expect(tsconfig.color).toBe('#3178c6');
|
||||
});
|
||||
|
||||
it('returns lock icon for sensitive files', () => {
|
||||
const env = getFileIcon('.env');
|
||||
expect(env.color).toBe('#e5a00d');
|
||||
|
||||
const pnpmLock = getFileIcon('pnpm-lock.yaml');
|
||||
expect(pnpmLock.color).toBe('#f69220');
|
||||
});
|
||||
|
||||
it('handles image files', () => {
|
||||
const png = getFileIcon('logo.png');
|
||||
expect(png.color).toBe('#a074c4');
|
||||
|
||||
const svg = getFileIcon('icon.svg');
|
||||
expect(svg.color).toBe('#ffb13b');
|
||||
});
|
||||
});
|
||||
278
test/renderer/hooks/useEditorKeyboardShortcuts.test.ts
Normal file
278
test/renderer/hooks/useEditorKeyboardShortcuts.test.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* Tests for createEditorKeyHandler — the pure keyboard dispatch logic
|
||||
* extracted from useEditorKeyboardShortcuts.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock @codemirror/search — handler calls openSearchPanel/gotoLine when view exists
|
||||
vi.mock('@codemirror/search', () => ({
|
||||
openSearchPanel: vi.fn(),
|
||||
gotoLine: vi.fn(),
|
||||
}));
|
||||
|
||||
import { gotoLine, openSearchPanel } from '@codemirror/search';
|
||||
import { createEditorKeyHandler } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
||||
|
||||
import type { EditorKeyHandlerDeps } from '@renderer/hooks/useEditorKeyboardShortcuts';
|
||||
import type { EditorFileTab } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function createMockDeps(overrides: Partial<EditorKeyHandlerDeps> = {}): EditorKeyHandlerDeps {
|
||||
return {
|
||||
activeTabId: '/project/file1.ts',
|
||||
openTabs: [
|
||||
{
|
||||
id: '/project/file1.ts',
|
||||
filePath: '/project/file1.ts',
|
||||
fileName: 'file1.ts',
|
||||
language: 'typescript',
|
||||
},
|
||||
{
|
||||
id: '/project/file2.ts',
|
||||
filePath: '/project/file2.ts',
|
||||
fileName: 'file2.ts',
|
||||
language: 'typescript',
|
||||
},
|
||||
{
|
||||
id: '/project/file3.ts',
|
||||
filePath: '/project/file3.ts',
|
||||
fileName: 'file3.ts',
|
||||
language: 'typescript',
|
||||
},
|
||||
] as EditorFileTab[],
|
||||
setActiveTab: vi.fn(),
|
||||
saveFile: vi.fn().mockResolvedValue(undefined),
|
||||
saveAllFiles: vi.fn().mockResolvedValue(undefined),
|
||||
hasUnsavedChanges: vi.fn().mockReturnValue(false),
|
||||
onToggleQuickOpen: vi.fn(),
|
||||
onToggleSearchPanel: vi.fn(),
|
||||
onToggleSidebar: vi.fn(),
|
||||
getEditorView: vi.fn().mockReturnValue(null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createKeyEvent(key: string, opts: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
||||
return new KeyboardEvent('keydown', {
|
||||
key,
|
||||
metaKey: opts.metaKey ?? true,
|
||||
ctrlKey: opts.ctrlKey ?? false,
|
||||
shiftKey: opts.shiftKey ?? false,
|
||||
altKey: opts.altKey ?? false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('createEditorKeyHandler', () => {
|
||||
let deps: EditorKeyHandlerDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
deps = createMockDeps();
|
||||
});
|
||||
|
||||
it('ignores events without modifier key', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
const event = new KeyboardEvent('keydown', { key: 'p', bubbles: true, cancelable: true });
|
||||
handler(event);
|
||||
expect(deps.onToggleQuickOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Cmd+P — Quick Open', () => {
|
||||
it('calls onToggleQuickOpen', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
const event = createKeyEvent('p');
|
||||
handler(event);
|
||||
expect(deps.onToggleQuickOpen).toHaveBeenCalledOnce();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trigger with Shift', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('p', { shiftKey: true }));
|
||||
expect(deps.onToggleQuickOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cmd+Shift+F — Search in Files', () => {
|
||||
it('calls onToggleSearchPanel', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
const event = createKeyEvent('f', { shiftKey: true });
|
||||
handler(event);
|
||||
expect(deps.onToggleSearchPanel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cmd+F — Find in File (CM6)', () => {
|
||||
it('calls openSearchPanel when editor view exists', () => {
|
||||
const mockView = { dispatch: vi.fn() };
|
||||
deps = createMockDeps({ getEditorView: vi.fn().mockReturnValue(mockView) });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('f'));
|
||||
expect(openSearchPanel).toHaveBeenCalledWith(mockView);
|
||||
});
|
||||
|
||||
it('does nothing when no editor view', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('f'));
|
||||
expect(openSearchPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cmd+G — Go to Line', () => {
|
||||
it('calls gotoLine when editor view exists', () => {
|
||||
const mockView = { dispatch: vi.fn() };
|
||||
deps = createMockDeps({ getEditorView: vi.fn().mockReturnValue(mockView) });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('g'));
|
||||
expect(gotoLine).toHaveBeenCalledWith(mockView);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cmd+S — Save', () => {
|
||||
it('calls saveFile with active tab id', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('s'));
|
||||
expect(deps.saveFile).toHaveBeenCalledWith('/project/file1.ts');
|
||||
});
|
||||
|
||||
it('does nothing when no active tab', () => {
|
||||
deps = createMockDeps({ activeTabId: null });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('s'));
|
||||
expect(deps.saveFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cmd+Shift+S — Save All', () => {
|
||||
it('calls saveAllFiles when unsaved changes exist', () => {
|
||||
deps = createMockDeps({ hasUnsavedChanges: vi.fn().mockReturnValue(true) });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('s', { shiftKey: true }));
|
||||
expect(deps.saveAllFiles).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does nothing when no unsaved changes', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('s', { shiftKey: true }));
|
||||
expect(deps.saveAllFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cmd+W — Close Tab', () => {
|
||||
it('dispatches editor-close-tab CustomEvent with active tab id', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
const eventSpy = vi.fn();
|
||||
window.addEventListener('editor-close-tab', eventSpy);
|
||||
|
||||
handler(createKeyEvent('w'));
|
||||
|
||||
expect(eventSpy).toHaveBeenCalledOnce();
|
||||
const detail = (eventSpy.mock.calls[0][0] as CustomEvent).detail;
|
||||
expect(detail).toBe('/project/file1.ts');
|
||||
|
||||
window.removeEventListener('editor-close-tab', eventSpy);
|
||||
});
|
||||
|
||||
it('does nothing with Alt modifier', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
const eventSpy = vi.fn();
|
||||
window.addEventListener('editor-close-tab', eventSpy);
|
||||
|
||||
handler(createKeyEvent('w', { altKey: true }));
|
||||
expect(eventSpy).not.toHaveBeenCalled();
|
||||
|
||||
window.removeEventListener('editor-close-tab', eventSpy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cmd+B — Toggle Sidebar', () => {
|
||||
it('calls onToggleSidebar', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('b'));
|
||||
expect(deps.onToggleSidebar).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cmd+Shift+] / [ — Tab Navigation', () => {
|
||||
it('moves to next tab with Cmd+Shift+]', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent(']', { shiftKey: true }));
|
||||
expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file2.ts');
|
||||
});
|
||||
|
||||
it('wraps to first tab when on last', () => {
|
||||
deps = createMockDeps({ activeTabId: '/project/file3.ts' });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent(']', { shiftKey: true }));
|
||||
expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file1.ts');
|
||||
});
|
||||
|
||||
it('moves to previous tab with Cmd+Shift+[', () => {
|
||||
deps = createMockDeps({ activeTabId: '/project/file2.ts' });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('[', { shiftKey: true }));
|
||||
expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file1.ts');
|
||||
});
|
||||
|
||||
it('wraps to last tab when on first with Cmd+Shift+[', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('[', { shiftKey: true }));
|
||||
expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file3.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ctrl+Tab — Tab Cycling', () => {
|
||||
it('moves to next tab', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true }));
|
||||
expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file2.ts');
|
||||
});
|
||||
|
||||
it('moves to previous tab with Shift', () => {
|
||||
deps = createMockDeps({ activeTabId: '/project/file2.ts' });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true, shiftKey: true }));
|
||||
expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file1.ts');
|
||||
});
|
||||
|
||||
it('wraps forward on last tab', () => {
|
||||
deps = createMockDeps({ activeTabId: '/project/file3.ts' });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true }));
|
||||
expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file1.ts');
|
||||
});
|
||||
|
||||
it('wraps backward on first tab', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent('Tab', { metaKey: false, ctrlKey: true, shiftKey: true }));
|
||||
expect(deps.setActiveTab).toHaveBeenCalledWith('/project/file3.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('does nothing when openTabs is empty', () => {
|
||||
deps = createMockDeps({ openTabs: [], activeTabId: null });
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
handler(createKeyEvent(']', { shiftKey: true }));
|
||||
expect(deps.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stopPropagation is called on handled shortcuts', () => {
|
||||
const handler = createEditorKeyHandler(deps);
|
||||
const event = createKeyEvent('p');
|
||||
const spy = vi.spyOn(event, 'stopPropagation');
|
||||
handler(event);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
852
test/renderer/store/editorSlice.test.ts
Normal file
852
test/renderer/store/editorSlice.test.ts
Normal file
|
|
@ -0,0 +1,852 @@
|
|||
/**
|
||||
* Tests for editorSlice — openEditor, closeEditor, expandDirectory, collapseDirectory.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createTestStore } from './storeTestUtils';
|
||||
|
||||
import type { TestStore } from './storeTestUtils';
|
||||
import type { FileTreeEntry, ReadDirResult } from '../../../src/shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Mock API
|
||||
// =============================================================================
|
||||
|
||||
const mockEditorAPI = {
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
readDir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
createFile: vi.fn(),
|
||||
createDir: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
moveFile: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
editor: {
|
||||
open: (...args: unknown[]) => mockEditorAPI.open(...args),
|
||||
close: (...args: unknown[]) => mockEditorAPI.close(...args),
|
||||
readDir: (...args: unknown[]) => mockEditorAPI.readDir(...args),
|
||||
readFile: (...args: unknown[]) => mockEditorAPI.readFile(...args),
|
||||
writeFile: (...args: unknown[]) => mockEditorAPI.writeFile(...args),
|
||||
createFile: (...args: unknown[]) => mockEditorAPI.createFile(...args),
|
||||
createDir: (...args: unknown[]) => mockEditorAPI.createDir(...args),
|
||||
deleteFile: (...args: unknown[]) => mockEditorAPI.deleteFile(...args),
|
||||
moveFile: (...args: unknown[]) => mockEditorAPI.moveFile(...args),
|
||||
},
|
||||
// Provide stubs for other API domains if needed
|
||||
getProjects: vi.fn(),
|
||||
getSessions: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockBridge = {
|
||||
getContent: vi.fn(),
|
||||
getAllModifiedContent: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
deleteState: vi.fn(),
|
||||
remapState: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@renderer/utils/editorBridge', () => ({
|
||||
editorBridge: {
|
||||
getContent: (...args: unknown[]) => mockBridge.getContent(...args),
|
||||
getAllModifiedContent: (...args: unknown[]) => mockBridge.getAllModifiedContent(...args),
|
||||
destroy: (...args: unknown[]) => mockBridge.destroy(...args),
|
||||
deleteState: (...args: unknown[]) => mockBridge.deleteState(...args),
|
||||
remapState: (...args: unknown[]) => mockBridge.remapState(...args),
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
isRegistered: false,
|
||||
updateView: vi.fn(),
|
||||
getView: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/utils/codemirrorLanguages', () => ({
|
||||
getLanguageFromFileName: (name: string) => {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
ts: 'TypeScript',
|
||||
tsx: 'TSX',
|
||||
js: 'JavaScript',
|
||||
json: 'JSON',
|
||||
md: 'Markdown',
|
||||
py: 'Python',
|
||||
};
|
||||
return map[ext ?? ''] ?? 'Plain Text';
|
||||
},
|
||||
getSyncLanguageExtension: vi.fn(),
|
||||
getAsyncLanguageDesc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
const PROJECT_PATH = '/Users/test/my-project';
|
||||
|
||||
function makeEntry(name: string, type: 'file' | 'directory', absPath?: string): FileTreeEntry {
|
||||
return {
|
||||
name,
|
||||
path: absPath ?? `${PROJECT_PATH}/${name}`,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDirResult(entries: FileTreeEntry[], truncated = false): ReadDirResult {
|
||||
return { entries, truncated };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('editorSlice', () => {
|
||||
let store: TestStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
store = createTestStore();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('has null/empty defaults', () => {
|
||||
const state = store.getState();
|
||||
expect(state.editorProjectPath).toBeNull();
|
||||
expect(state.editorFileTree).toBeNull();
|
||||
expect(state.editorFileTreeLoading).toBe(false);
|
||||
expect(state.editorFileTreeError).toBeNull();
|
||||
expect(state.editorExpandedDirs).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openEditor', () => {
|
||||
it('sets loading state, calls API, and stores file tree', async () => {
|
||||
const entries = [makeEntry('src', 'directory'), makeEntry('README.md', 'file')];
|
||||
mockEditorAPI.open.mockResolvedValue(undefined);
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult(entries));
|
||||
|
||||
await store.getState().openEditor(PROJECT_PATH);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.editorProjectPath).toBe(PROJECT_PATH);
|
||||
expect(state.editorFileTree).toEqual(entries);
|
||||
expect(state.editorFileTreeLoading).toBe(false);
|
||||
expect(state.editorFileTreeError).toBeNull();
|
||||
expect(mockEditorAPI.open).toHaveBeenCalledWith(PROJECT_PATH);
|
||||
expect(mockEditorAPI.readDir).toHaveBeenCalledWith(PROJECT_PATH);
|
||||
});
|
||||
|
||||
it('sets error state on API failure', async () => {
|
||||
mockEditorAPI.open.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await store.getState().openEditor(PROJECT_PATH);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.editorFileTreeLoading).toBe(false);
|
||||
expect(state.editorFileTreeError).toBe('Permission denied');
|
||||
expect(state.editorFileTree).toBeNull();
|
||||
});
|
||||
|
||||
it('resets expanded dirs on new open', async () => {
|
||||
// Set some expanded dirs manually
|
||||
store.setState({ editorExpandedDirs: { '/old/path': true } });
|
||||
|
||||
mockEditorAPI.open.mockResolvedValue(undefined);
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||
|
||||
await store.getState().openEditor(PROJECT_PATH);
|
||||
|
||||
expect(store.getState().editorExpandedDirs).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeEditor', () => {
|
||||
it('resets all editor state', async () => {
|
||||
// Setup non-default state
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorFileTree: [makeEntry('file.ts', 'file')],
|
||||
editorFileTreeLoading: true,
|
||||
editorFileTreeError: 'some error',
|
||||
editorExpandedDirs: { '/path': true },
|
||||
});
|
||||
|
||||
mockEditorAPI.close.mockResolvedValue(undefined);
|
||||
|
||||
store.getState().closeEditor();
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.editorProjectPath).toBeNull();
|
||||
expect(state.editorFileTree).toBeNull();
|
||||
expect(state.editorFileTreeLoading).toBe(false);
|
||||
expect(state.editorFileTreeError).toBeNull();
|
||||
expect(state.editorExpandedDirs).toEqual({});
|
||||
});
|
||||
|
||||
it('still resets local state even if IPC close fails', async () => {
|
||||
store.setState({ editorProjectPath: PROJECT_PATH });
|
||||
mockEditorAPI.close.mockRejectedValue(new Error('IPC error'));
|
||||
|
||||
store.getState().closeEditor();
|
||||
|
||||
// Local state reset immediately (fire-and-forget IPC)
|
||||
expect(store.getState().editorProjectPath).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandDirectory', () => {
|
||||
it('marks directory expanded immediately, then merges children', async () => {
|
||||
const srcDir = makeEntry('src', 'directory');
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorFileTree: [srcDir, makeEntry('README.md', 'file')],
|
||||
});
|
||||
|
||||
const children = [makeEntry('index.ts', 'file', `${PROJECT_PATH}/src/index.ts`)];
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult(children));
|
||||
|
||||
const expandPromise = store.getState().expandDirectory(srcDir.path);
|
||||
|
||||
// Immediately expanded (optimistic UI)
|
||||
expect(store.getState().editorExpandedDirs[srcDir.path]).toBe(true);
|
||||
|
||||
await expandPromise;
|
||||
|
||||
// Children merged into tree
|
||||
const tree = store.getState().editorFileTree!;
|
||||
const srcNode = tree.find((e) => e.name === 'src');
|
||||
expect(srcNode?.children).toEqual(children);
|
||||
});
|
||||
|
||||
it('reverts expansion on error', async () => {
|
||||
const srcDir = makeEntry('src', 'directory');
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorFileTree: [srcDir],
|
||||
editorExpandedDirs: {},
|
||||
});
|
||||
|
||||
mockEditorAPI.readDir.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await store.getState().expandDirectory(srcDir.path);
|
||||
|
||||
// Expansion reverted
|
||||
expect(store.getState().editorExpandedDirs[srcDir.path]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseDirectory', () => {
|
||||
it('removes directory from expandedDirs', () => {
|
||||
const dirPath = PROJECT_PATH + '/src';
|
||||
store.setState({ editorExpandedDirs: { [dirPath]: true, '/other': true } });
|
||||
|
||||
store.getState().collapseDirectory(dirPath);
|
||||
|
||||
expect(store.getState().editorExpandedDirs).toEqual({ '/other': true });
|
||||
});
|
||||
|
||||
it('no-op when directory not expanded', () => {
|
||||
store.setState({ editorExpandedDirs: { '/other': true } });
|
||||
|
||||
store.getState().collapseDirectory('/not-expanded');
|
||||
|
||||
expect(store.getState().editorExpandedDirs).toEqual({ '/other': true });
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 2: Tab management
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
describe('openFile', () => {
|
||||
it('creates a tab and activates it', () => {
|
||||
store.getState().openFile('/project/src/index.ts');
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.editorOpenTabs).toHaveLength(1);
|
||||
expect(state.editorOpenTabs[0].filePath).toBe('/project/src/index.ts');
|
||||
expect(state.editorOpenTabs[0].fileName).toBe('index.ts');
|
||||
expect(state.editorOpenTabs[0].language).toBe('TypeScript');
|
||||
expect(state.editorActiveTabId).toBe('/project/src/index.ts');
|
||||
});
|
||||
|
||||
it('activates existing tab instead of creating duplicate', () => {
|
||||
store.getState().openFile('/project/src/index.ts');
|
||||
store.getState().openFile('/project/src/app.tsx');
|
||||
store.getState().openFile('/project/src/index.ts');
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.editorOpenTabs).toHaveLength(2);
|
||||
expect(state.editorActiveTabId).toBe('/project/src/index.ts');
|
||||
});
|
||||
|
||||
it('detects language from file extension', () => {
|
||||
store.getState().openFile('/project/data.json');
|
||||
|
||||
expect(store.getState().editorOpenTabs[0].language).toBe('JSON');
|
||||
});
|
||||
|
||||
it('uses "Plain Text" for unknown extensions', () => {
|
||||
store.getState().openFile('/project/Dockerfile');
|
||||
|
||||
expect(store.getState().editorOpenTabs[0].language).toBe('Plain Text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeTab', () => {
|
||||
it('removes tab and activates adjacent', () => {
|
||||
store.getState().openFile('/project/a.ts');
|
||||
store.getState().openFile('/project/b.ts');
|
||||
store.getState().openFile('/project/c.ts');
|
||||
|
||||
// Active is c.ts, close it
|
||||
store.getState().closeTab('/project/c.ts');
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.editorOpenTabs).toHaveLength(2);
|
||||
expect(state.editorActiveTabId).toBe('/project/b.ts');
|
||||
});
|
||||
|
||||
it('activates first remaining tab when first is closed', () => {
|
||||
store.getState().openFile('/project/a.ts');
|
||||
store.getState().openFile('/project/b.ts');
|
||||
store.getState().setActiveTab('/project/a.ts');
|
||||
|
||||
store.getState().closeTab('/project/a.ts');
|
||||
|
||||
expect(store.getState().editorActiveTabId).toBe('/project/b.ts');
|
||||
});
|
||||
|
||||
it('sets null when last tab is closed', () => {
|
||||
store.getState().openFile('/project/a.ts');
|
||||
store.getState().closeTab('/project/a.ts');
|
||||
|
||||
expect(store.getState().editorActiveTabId).toBeNull();
|
||||
expect(store.getState().editorOpenTabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cleans up dirty and error state for closed tab', () => {
|
||||
store.getState().openFile('/project/a.ts');
|
||||
store.setState({
|
||||
editorModifiedFiles: { '/project/a.ts': true },
|
||||
editorSaveError: { '/project/a.ts': 'Save failed' },
|
||||
});
|
||||
|
||||
store.getState().closeTab('/project/a.ts');
|
||||
|
||||
expect(store.getState().editorModifiedFiles).toEqual({});
|
||||
expect(store.getState().editorSaveError).toEqual({});
|
||||
});
|
||||
|
||||
it('does not change activeTabId when closing non-active tab', () => {
|
||||
store.getState().openFile('/project/a.ts');
|
||||
store.getState().openFile('/project/b.ts');
|
||||
// b.ts is active
|
||||
|
||||
store.getState().closeTab('/project/a.ts');
|
||||
|
||||
expect(store.getState().editorActiveTabId).toBe('/project/b.ts');
|
||||
expect(store.getState().editorOpenTabs).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveTab', () => {
|
||||
it('changes the active tab', () => {
|
||||
store.getState().openFile('/project/a.ts');
|
||||
store.getState().openFile('/project/b.ts');
|
||||
// b.ts is active
|
||||
|
||||
store.getState().setActiveTab('/project/a.ts');
|
||||
|
||||
expect(store.getState().editorActiveTabId).toBe('/project/a.ts');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 3: Dirty/Save
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
describe('markFileModified', () => {
|
||||
it('sets dirty flag', () => {
|
||||
store.getState().markFileModified('/project/a.ts');
|
||||
|
||||
expect(store.getState().editorModifiedFiles['/project/a.ts']).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
store.getState().markFileModified('/project/a.ts');
|
||||
const first = store.getState().editorModifiedFiles;
|
||||
|
||||
store.getState().markFileModified('/project/a.ts');
|
||||
const second = store.getState().editorModifiedFiles;
|
||||
|
||||
// Same reference (no unnecessary update)
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markFileSaved', () => {
|
||||
it('removes dirty flag', () => {
|
||||
store.setState({ editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true } });
|
||||
|
||||
store.getState().markFileSaved('/project/a.ts');
|
||||
|
||||
expect(store.getState().editorModifiedFiles).toEqual({ '/project/b.ts': true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUnsavedChanges', () => {
|
||||
it('returns false when no modified files', () => {
|
||||
expect(store.getState().hasUnsavedChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when modified files exist', () => {
|
||||
store.setState({ editorModifiedFiles: { '/project/a.ts': true } });
|
||||
expect(store.getState().hasUnsavedChanges()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveFile', () => {
|
||||
it('saves file via API and clears dirty flag', async () => {
|
||||
const filePath = '/project/src/index.ts';
|
||||
mockBridge.getContent.mockReturnValue('new content');
|
||||
mockEditorAPI.writeFile.mockResolvedValue({ mtimeMs: Date.now(), size: 11 });
|
||||
|
||||
store.setState({ editorModifiedFiles: { [filePath]: true } });
|
||||
await store.getState().saveFile(filePath);
|
||||
|
||||
expect(mockBridge.getContent).toHaveBeenCalledWith(filePath);
|
||||
expect(mockEditorAPI.writeFile).toHaveBeenCalledWith(filePath, 'new content', undefined);
|
||||
expect(store.getState().editorModifiedFiles[filePath]).toBeUndefined();
|
||||
expect(store.getState().editorSaving[filePath]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets saving flag during save', async () => {
|
||||
const filePath = '/project/src/index.ts';
|
||||
let savingDuringCall = false;
|
||||
|
||||
mockBridge.getContent.mockReturnValue('content');
|
||||
mockEditorAPI.writeFile.mockImplementation(async () => {
|
||||
savingDuringCall = !!store.getState().editorSaving[filePath];
|
||||
return { mtimeMs: Date.now(), size: 7 };
|
||||
});
|
||||
|
||||
await store.getState().saveFile(filePath);
|
||||
|
||||
expect(savingDuringCall).toBe(true);
|
||||
expect(store.getState().editorSaving[filePath]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing when bridge has no content', async () => {
|
||||
mockBridge.getContent.mockReturnValue(null);
|
||||
|
||||
await store.getState().saveFile('/project/src/index.ts');
|
||||
|
||||
expect(mockEditorAPI.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets error on save failure', async () => {
|
||||
const filePath = '/project/src/index.ts';
|
||||
mockBridge.getContent.mockReturnValue('content');
|
||||
mockEditorAPI.writeFile.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
store.setState({ editorModifiedFiles: { [filePath]: true } });
|
||||
await store.getState().saveFile(filePath);
|
||||
|
||||
expect(store.getState().editorSaveError[filePath]).toBe('Permission denied');
|
||||
// Dirty flag preserved on error
|
||||
expect(store.getState().editorModifiedFiles[filePath]).toBe(true);
|
||||
expect(store.getState().editorSaving[filePath]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveAllFiles', () => {
|
||||
it('saves all modified files', async () => {
|
||||
const files = new Map([
|
||||
['/project/a.ts', 'content a'],
|
||||
['/project/b.ts', 'content b'],
|
||||
]);
|
||||
mockBridge.getAllModifiedContent.mockReturnValue(files);
|
||||
mockEditorAPI.writeFile.mockResolvedValue({ mtimeMs: Date.now(), size: 10 });
|
||||
|
||||
store.setState({
|
||||
editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true },
|
||||
});
|
||||
|
||||
await store.getState().saveAllFiles();
|
||||
|
||||
expect(mockEditorAPI.writeFile).toHaveBeenCalledTimes(2);
|
||||
expect(store.getState().editorModifiedFiles).toEqual({});
|
||||
});
|
||||
|
||||
it('handles partial failures', async () => {
|
||||
const files = new Map([
|
||||
['/project/a.ts', 'content a'],
|
||||
['/project/b.ts', 'content b'],
|
||||
]);
|
||||
mockBridge.getAllModifiedContent.mockReturnValue(files);
|
||||
mockEditorAPI.writeFile
|
||||
.mockResolvedValueOnce({ mtimeMs: Date.now(), size: 10 })
|
||||
.mockRejectedValueOnce(new Error('Disk full'));
|
||||
|
||||
store.setState({
|
||||
editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true },
|
||||
});
|
||||
|
||||
await store.getState().saveAllFiles();
|
||||
|
||||
// a.ts saved, b.ts still dirty
|
||||
expect(store.getState().editorModifiedFiles['/project/a.ts']).toBeUndefined();
|
||||
expect(store.getState().editorModifiedFiles['/project/b.ts']).toBe(true);
|
||||
expect(store.getState().editorSaveError['/project/b.ts']).toBe('Disk full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('discardChanges', () => {
|
||||
it('clears dirty flag and error for the file', () => {
|
||||
store.setState({
|
||||
editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true },
|
||||
editorSaveError: { '/project/a.ts': 'Error' },
|
||||
});
|
||||
|
||||
store.getState().discardChanges('/project/a.ts');
|
||||
|
||||
expect(store.getState().editorModifiedFiles).toEqual({ '/project/b.ts': true });
|
||||
expect(store.getState().editorSaveError).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeEditor resets all state including Group 2+3', () => {
|
||||
it('resets tabs, dirty, saving, errors', () => {
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorOpenTabs: [
|
||||
{ id: '/a.ts', filePath: '/a.ts', fileName: 'a.ts', language: 'TypeScript' },
|
||||
],
|
||||
editorActiveTabId: '/a.ts',
|
||||
editorModifiedFiles: { '/a.ts': true },
|
||||
editorSaving: { '/a.ts': true },
|
||||
editorSaveError: { '/a.ts': 'Error' },
|
||||
});
|
||||
|
||||
mockEditorAPI.close.mockResolvedValue(undefined);
|
||||
store.getState().closeEditor();
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.editorOpenTabs).toEqual([]);
|
||||
expect(state.editorActiveTabId).toBeNull();
|
||||
expect(state.editorModifiedFiles).toEqual({});
|
||||
expect(state.editorSaving).toEqual({});
|
||||
expect(state.editorSaveError).toEqual({});
|
||||
expect(mockBridge.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Tab disambiguation
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
describe('openFile with disambiguation', () => {
|
||||
it('adds disambiguation labels when 2 files share the same name', () => {
|
||||
store.getState().openFile('/project/src/main/index.ts');
|
||||
store.getState().openFile('/project/src/renderer/index.ts');
|
||||
|
||||
const tabs = store.getState().editorOpenTabs;
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(tabs[0].disambiguatedLabel).toBe('(main)');
|
||||
expect(tabs[1].disambiguatedLabel).toBe('(renderer)');
|
||||
});
|
||||
|
||||
it('no labels when names are unique', () => {
|
||||
store.getState().openFile('/project/src/app.ts');
|
||||
store.getState().openFile('/project/src/index.ts');
|
||||
|
||||
const tabs = store.getState().editorOpenTabs;
|
||||
expect(tabs[0].disambiguatedLabel).toBeUndefined();
|
||||
expect(tabs[1].disambiguatedLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeTab clears disambiguation when names become unique', () => {
|
||||
it('removes label after closing duplicate', () => {
|
||||
store.getState().openFile('/project/src/main/index.ts');
|
||||
store.getState().openFile('/project/src/renderer/index.ts');
|
||||
|
||||
// Both have labels
|
||||
expect(store.getState().editorOpenTabs[0].disambiguatedLabel).toBe('(main)');
|
||||
|
||||
// Close one
|
||||
store.getState().closeTab('/project/src/main/index.ts');
|
||||
|
||||
// Remaining should lose its label
|
||||
const tabs = store.getState().editorOpenTabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0].disambiguatedLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeTab calls editorBridge.deleteState', () => {
|
||||
it('clears cached state for the closed tab', () => {
|
||||
store.getState().openFile('/project/a.ts');
|
||||
store.getState().closeTab('/project/a.ts');
|
||||
|
||||
expect(mockBridge.deleteState).toHaveBeenCalledWith('/project/a.ts');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Group 4: File operations
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
describe('createFileInTree', () => {
|
||||
it('creates file, refreshes tree, and returns path', async () => {
|
||||
const createdPath = '/project/src/new-file.ts';
|
||||
mockEditorAPI.createFile.mockResolvedValue({ filePath: createdPath, mtimeMs: 123 });
|
||||
mockEditorAPI.readDir.mockResolvedValue(
|
||||
makeDirResult([makeEntry('new-file.ts', 'file', createdPath)])
|
||||
);
|
||||
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorFileTree: [makeEntry('src', 'directory')],
|
||||
});
|
||||
|
||||
const result = await store.getState().createFileInTree('/project/src', 'new-file.ts');
|
||||
|
||||
expect(result).toBe(createdPath);
|
||||
expect(mockEditorAPI.createFile).toHaveBeenCalledWith('/project/src', 'new-file.ts');
|
||||
expect(store.getState().editorCreating).toBe(false);
|
||||
expect(store.getState().editorCreateError).toBeNull();
|
||||
});
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockEditorAPI.createFile.mockRejectedValue(new Error('File already exists'));
|
||||
|
||||
const result = await store.getState().createFileInTree('/project/src', 'existing.ts');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(store.getState().editorCreating).toBe(false);
|
||||
expect(store.getState().editorCreateError).toBe('File already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDirInTree', () => {
|
||||
it('creates directory, refreshes tree, and returns path', async () => {
|
||||
const createdPath = '/project/src/new-dir';
|
||||
mockEditorAPI.createDir.mockResolvedValue({ dirPath: createdPath });
|
||||
mockEditorAPI.readDir.mockResolvedValue(
|
||||
makeDirResult([makeEntry('new-dir', 'directory', createdPath)])
|
||||
);
|
||||
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorFileTree: [makeEntry('src', 'directory')],
|
||||
});
|
||||
|
||||
const result = await store.getState().createDirInTree('/project/src', 'new-dir');
|
||||
|
||||
expect(result).toBe(createdPath);
|
||||
expect(mockEditorAPI.createDir).toHaveBeenCalledWith('/project/src', 'new-dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFileFromTree', () => {
|
||||
it('deletes file and closes its tab if open', async () => {
|
||||
mockEditorAPI.deleteFile.mockResolvedValue({ deletedPath: '/project/src/old.ts' });
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||
|
||||
store.getState().openFile('/project/src/old.ts');
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorFileTree: [makeEntry('src', 'directory')],
|
||||
});
|
||||
|
||||
const result = await store.getState().deleteFileFromTree('/project/src/old.ts');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockEditorAPI.deleteFile).toHaveBeenCalledWith('/project/src/old.ts');
|
||||
// Tab should be closed
|
||||
expect(store.getState().editorOpenTabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns false on failure', async () => {
|
||||
mockEditorAPI.deleteFile.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await store.getState().deleteFileFromTree('/project/src/file.ts');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('closes tabs for files inside deleted directory', async () => {
|
||||
mockEditorAPI.deleteFile.mockResolvedValue({ deletedPath: '/project/src' });
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||
|
||||
store.getState().openFile('/project/src/a.ts');
|
||||
store.getState().openFile('/project/src/b.ts');
|
||||
store.getState().openFile('/project/other.ts');
|
||||
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorFileTree: [makeEntry('src', 'directory'), makeEntry('other.ts', 'file')],
|
||||
});
|
||||
|
||||
await store.getState().deleteFileFromTree('/project/src');
|
||||
|
||||
// Only other.ts should remain
|
||||
expect(store.getState().editorOpenTabs).toHaveLength(1);
|
||||
expect(store.getState().editorOpenTabs[0].filePath).toBe('/project/other.ts');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// moveFileInTree
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
describe('moveFileInTree', () => {
|
||||
const SRC_DIR = PROJECT_PATH + '/src';
|
||||
const LIB_DIR = PROJECT_PATH + '/lib';
|
||||
|
||||
it('moves file, updates tabs, and returns true', async () => {
|
||||
const oldPath = SRC_DIR + '/utils.ts';
|
||||
const newPath = LIB_DIR + '/utils.ts';
|
||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||
|
||||
store.getState().openFile(oldPath);
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorFileTree: [makeEntry('src', 'directory'), makeEntry('lib', 'directory')],
|
||||
});
|
||||
|
||||
const result = await store.getState().moveFileInTree(oldPath, LIB_DIR);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockEditorAPI.moveFile).toHaveBeenCalledWith(oldPath, LIB_DIR);
|
||||
|
||||
// Tab should be remapped to new path
|
||||
const tabs = store.getState().editorOpenTabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0].filePath).toBe(newPath);
|
||||
expect(tabs[0].id).toBe(newPath);
|
||||
expect(tabs[0].fileName).toBe('utils.ts');
|
||||
});
|
||||
|
||||
it('remaps activeTabId when moved file is active', async () => {
|
||||
const oldPath = SRC_DIR + '/index.ts';
|
||||
const newPath = LIB_DIR + '/index.ts';
|
||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||
|
||||
store.getState().openFile(oldPath);
|
||||
store.setState({ editorProjectPath: PROJECT_PATH });
|
||||
|
||||
await store.getState().moveFileInTree(oldPath, LIB_DIR);
|
||||
|
||||
expect(store.getState().editorActiveTabId).toBe(newPath);
|
||||
});
|
||||
|
||||
it('remaps modifiedFiles and fileMtimes', async () => {
|
||||
const oldPath = SRC_DIR + '/dirty.ts';
|
||||
const newPath = LIB_DIR + '/dirty.ts';
|
||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorModifiedFiles: { [oldPath]: true },
|
||||
editorFileMtimes: { [oldPath]: 123456 },
|
||||
});
|
||||
|
||||
await store.getState().moveFileInTree(oldPath, LIB_DIR);
|
||||
|
||||
expect(store.getState().editorModifiedFiles[newPath]).toBe(true);
|
||||
expect(store.getState().editorModifiedFiles[oldPath]).toBeUndefined();
|
||||
expect(store.getState().editorFileMtimes[newPath]).toBe(123456);
|
||||
expect(store.getState().editorFileMtimes[oldPath]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles directory move (prefix remapping of nested tabs)', async () => {
|
||||
const oldDir = SRC_DIR + '/components';
|
||||
const newDir = LIB_DIR + '/components';
|
||||
const oldFilePath = oldDir + '/Button.tsx';
|
||||
const newFilePath = newDir + '/Button.tsx';
|
||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath: newDir });
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||
|
||||
store.getState().openFile(oldFilePath);
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorModifiedFiles: { [oldFilePath]: true },
|
||||
editorExpandedDirs: { [oldDir]: true },
|
||||
});
|
||||
|
||||
await store.getState().moveFileInTree(oldDir, LIB_DIR);
|
||||
|
||||
// Tab should be remapped
|
||||
const tabs = store.getState().editorOpenTabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0].filePath).toBe(newFilePath);
|
||||
|
||||
// Modified files remapped
|
||||
expect(store.getState().editorModifiedFiles[newFilePath]).toBe(true);
|
||||
expect(store.getState().editorModifiedFiles[oldFilePath]).toBeUndefined();
|
||||
|
||||
// Expanded dirs remapped
|
||||
expect(store.getState().editorExpandedDirs[newDir]).toBe(true);
|
||||
expect(store.getState().editorExpandedDirs[oldDir]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks during save', async () => {
|
||||
const filePath = SRC_DIR + '/saving.ts';
|
||||
store.setState({
|
||||
editorProjectPath: PROJECT_PATH,
|
||||
editorSaving: { [filePath]: true },
|
||||
});
|
||||
|
||||
const result = await store.getState().moveFileInTree(filePath, LIB_DIR);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockEditorAPI.moveFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false on API error', async () => {
|
||||
const filePath = SRC_DIR + '/index.ts';
|
||||
mockEditorAPI.moveFile.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
store.setState({ editorProjectPath: PROJECT_PATH });
|
||||
|
||||
const result = await store.getState().moveFileInTree(filePath, LIB_DIR);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('calls editorBridge.remapState for affected files', async () => {
|
||||
const oldPath = SRC_DIR + '/bridge.ts';
|
||||
const newPath = LIB_DIR + '/bridge.ts';
|
||||
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
||||
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
||||
|
||||
store.getState().openFile(oldPath);
|
||||
store.setState({ editorProjectPath: PROJECT_PATH });
|
||||
|
||||
await store.getState().moveFileInTree(oldPath, LIB_DIR);
|
||||
|
||||
expect(mockBridge.remapState).toHaveBeenCalledWith(oldPath, newPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
import { createConfigSlice } from '../../../src/renderer/store/slices/configSlice';
|
||||
import { createEditorSlice } from '../../../src/renderer/store/slices/editorSlice';
|
||||
import { createConversationSlice } from '../../../src/renderer/store/slices/conversationSlice';
|
||||
import { createNotificationSlice } from '../../../src/renderer/store/slices/notificationSlice';
|
||||
import { createPaneSlice } from '../../../src/renderer/store/slices/paneSlice';
|
||||
|
|
@ -37,6 +38,7 @@ export function createTestStore() {
|
|||
...createUISlice(...args),
|
||||
...createNotificationSlice(...args),
|
||||
...createConfigSlice(...args),
|
||||
...createEditorSlice(...args),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
87
test/renderer/utils/codemirrorLanguages.test.ts
Normal file
87
test/renderer/utils/codemirrorLanguages.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getAsyncLanguageDesc,
|
||||
getSyncLanguageExtension,
|
||||
} from '@renderer/utils/codemirrorLanguages';
|
||||
|
||||
describe('getSyncLanguageExtension', () => {
|
||||
it.each([
|
||||
['file.ts', true],
|
||||
['file.tsx', true],
|
||||
['file.js', true],
|
||||
['file.jsx', true],
|
||||
['file.mjs', true],
|
||||
['file.cjs', true],
|
||||
['file.py', true],
|
||||
['file.json', true],
|
||||
['file.jsonl', true],
|
||||
['file.css', true],
|
||||
['file.scss', true],
|
||||
['file.sass', true],
|
||||
['file.less', true],
|
||||
['file.html', true],
|
||||
['file.htm', true],
|
||||
['file.xml', true],
|
||||
['file.svg', true],
|
||||
['file.md', true],
|
||||
['file.mdx', true],
|
||||
['file.markdown', true],
|
||||
['file.yaml', true],
|
||||
['file.yml', true],
|
||||
['file.rs', true],
|
||||
['file.go', true],
|
||||
['file.java', true],
|
||||
['file.c', true],
|
||||
['file.h', true],
|
||||
['file.cpp', true],
|
||||
['file.cxx', true],
|
||||
['file.cc', true],
|
||||
['file.hpp', true],
|
||||
['file.php', true],
|
||||
['file.sql', true],
|
||||
])('returns extension for %s', (fileName, expected) => {
|
||||
const ext = getSyncLanguageExtension(fileName);
|
||||
expect(ext !== null).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns null for unknown extensions', () => {
|
||||
expect(getSyncLanguageExtension('file.unknown')).toBeNull();
|
||||
expect(getSyncLanguageExtension('file.dat')).toBeNull();
|
||||
expect(getSyncLanguageExtension('file.bin')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles files without extension', () => {
|
||||
expect(getSyncLanguageExtension('Makefile')).toBeNull();
|
||||
expect(getSyncLanguageExtension('Dockerfile')).toBeNull();
|
||||
});
|
||||
|
||||
it('is case-insensitive for extensions', () => {
|
||||
expect(getSyncLanguageExtension('file.TS')).not.toBeNull();
|
||||
expect(getSyncLanguageExtension('file.JSON')).not.toBeNull();
|
||||
expect(getSyncLanguageExtension('file.Py')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('handles nested paths', () => {
|
||||
expect(getSyncLanguageExtension('src/main/index.ts')).not.toBeNull();
|
||||
expect(getSyncLanguageExtension('deeply/nested/path/file.py')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAsyncLanguageDesc', () => {
|
||||
it('returns a LanguageDescription for known file types', () => {
|
||||
const desc = getAsyncLanguageDesc('file.rb');
|
||||
expect(desc).not.toBeNull();
|
||||
expect(desc!.name).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns null for completely unknown types', () => {
|
||||
const desc = getAsyncLanguageDesc('file.xyzabc123');
|
||||
expect(desc).toBeNull();
|
||||
});
|
||||
|
||||
it('works with full path', () => {
|
||||
const desc = getAsyncLanguageDesc('src/main.rb');
|
||||
expect(desc).not.toBeNull();
|
||||
});
|
||||
});
|
||||
184
test/renderer/utils/fileTreeBuilder.test.ts
Normal file
184
test/renderer/utils/fileTreeBuilder.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
||||
|
||||
import type { TreeNode } from '@renderer/utils/fileTreeBuilder';
|
||||
|
||||
interface TestItem {
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const getPath = (item: TestItem) => item.path;
|
||||
|
||||
describe('buildTree', () => {
|
||||
it('builds a flat list of files into a tree', () => {
|
||||
const items: TestItem[] = [
|
||||
{ path: 'src/main.ts', size: 100 },
|
||||
{ path: 'src/utils.ts', size: 50 },
|
||||
{ path: 'README.md', size: 30 },
|
||||
];
|
||||
|
||||
const tree = buildTree(items, getPath);
|
||||
|
||||
expect(tree).toHaveLength(2);
|
||||
|
||||
const src = tree.find((n) => n.name === 'src');
|
||||
expect(src).toBeDefined();
|
||||
expect(src!.isFile).toBe(false);
|
||||
expect(src!.children).toHaveLength(2);
|
||||
|
||||
const readme = tree.find((n) => n.name === 'README.md');
|
||||
expect(readme).toBeDefined();
|
||||
expect(readme!.isFile).toBe(true);
|
||||
expect(readme!.data).toEqual({ path: 'README.md', size: 30 });
|
||||
});
|
||||
|
||||
it('collapses single-child intermediate directories by default', () => {
|
||||
const items: TestItem[] = [{ path: 'a/b/c/file.ts', size: 10 }];
|
||||
|
||||
const tree = buildTree(items, getPath);
|
||||
|
||||
// a/b/c collapsed into one node
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe('a/b/c');
|
||||
expect(tree[0].isFile).toBe(false);
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].name).toBe('file.ts');
|
||||
expect(tree[0].children[0].isFile).toBe(true);
|
||||
});
|
||||
|
||||
it('does not collapse when collapse=false', () => {
|
||||
const items: TestItem[] = [{ path: 'a/b/file.ts', size: 10 }];
|
||||
|
||||
const tree = buildTree(items, getPath, { collapse: false });
|
||||
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe('a');
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].name).toBe('b');
|
||||
expect(tree[0].children[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].children[0].name).toBe('file.ts');
|
||||
});
|
||||
|
||||
it('does not collapse directories with multiple children', () => {
|
||||
const items: TestItem[] = [
|
||||
{ path: 'src/a/file1.ts', size: 10 },
|
||||
{ path: 'src/b/file2.ts', size: 20 },
|
||||
];
|
||||
|
||||
const tree = buildTree(items, getPath);
|
||||
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe('src');
|
||||
expect(tree[0].children).toHaveLength(2);
|
||||
// Each child is collapsed: a/ → file1.ts, b/ → file2.ts
|
||||
expect(tree[0].children.map((c) => c.name).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('preserves data only on leaf nodes', () => {
|
||||
const items: TestItem[] = [
|
||||
{ path: 'src/index.ts', size: 100 },
|
||||
{ path: 'src/utils/helper.ts', size: 50 },
|
||||
];
|
||||
|
||||
const tree = buildTree(items, getPath);
|
||||
const src = tree[0];
|
||||
|
||||
expect(src.data).toBeUndefined();
|
||||
const indexFile = src.children.find((c) => c.name === 'index.ts');
|
||||
expect(indexFile!.data).toEqual({ path: 'src/index.ts', size: 100 });
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
const tree = buildTree([], getPath);
|
||||
expect(tree).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles single file at root level', () => {
|
||||
const items: TestItem[] = [{ path: 'file.ts', size: 10 }];
|
||||
const tree = buildTree(items, getPath);
|
||||
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe('file.ts');
|
||||
expect(tree[0].isFile).toBe(true);
|
||||
expect(tree[0].children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles deeply nested paths', () => {
|
||||
const items: TestItem[] = [{ path: 'a/b/c/d/e/f.ts', size: 1 }];
|
||||
const tree = buildTree(items, getPath);
|
||||
|
||||
// Collapsed: a/b/c/d/e → f.ts
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe('a/b/c/d/e');
|
||||
expect(tree[0].children[0].name).toBe('f.ts');
|
||||
});
|
||||
|
||||
it('sets correct fullPath for all nodes', () => {
|
||||
const items: TestItem[] = [
|
||||
{ path: 'src/components/Button.tsx', size: 100 },
|
||||
{ path: 'src/components/Input.tsx', size: 80 },
|
||||
];
|
||||
|
||||
const tree = buildTree(items, getPath, { collapse: false });
|
||||
|
||||
const src = tree[0];
|
||||
expect(src.fullPath).toBe('src');
|
||||
const components = src.children[0];
|
||||
expect(components.fullPath).toBe('src/components');
|
||||
const button = components.children.find((c) => c.name === 'Button.tsx');
|
||||
expect(button!.fullPath).toBe('src/components/Button.tsx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortTreeNodes', () => {
|
||||
it('sorts directories before files', () => {
|
||||
const nodes: TreeNode<TestItem>[] = [
|
||||
{ name: 'beta.ts', fullPath: 'beta.ts', isFile: true, children: [] },
|
||||
{ name: 'src', fullPath: 'src', isFile: false, children: [] },
|
||||
{ name: 'alpha.ts', fullPath: 'alpha.ts', isFile: true, children: [] },
|
||||
{ name: 'lib', fullPath: 'lib', isFile: false, children: [] },
|
||||
];
|
||||
|
||||
const sorted = sortTreeNodes(nodes);
|
||||
const dirs = sorted.filter((n) => !n.isFile);
|
||||
const files = sorted.filter((n) => n.isFile);
|
||||
|
||||
// Directories come first
|
||||
expect(dirs.map((n) => n.name)).toEqual(['lib', 'src']);
|
||||
// Files come after
|
||||
expect(files.map((n) => n.name)).toEqual(['alpha.ts', 'beta.ts']);
|
||||
// Combined order
|
||||
expect(sorted.slice(0, 2).every((n) => !n.isFile)).toBe(true);
|
||||
expect(sorted.slice(2).every((n) => n.isFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('sorts alphabetically within same type', () => {
|
||||
const nodes: TreeNode<TestItem>[] = [
|
||||
{ name: 'zebra.ts', fullPath: 'zebra.ts', isFile: true, children: [] },
|
||||
{ name: 'alpha.ts', fullPath: 'alpha.ts', isFile: true, children: [] },
|
||||
{ name: 'mid.ts', fullPath: 'mid.ts', isFile: true, children: [] },
|
||||
];
|
||||
|
||||
const sorted = sortTreeNodes(nodes);
|
||||
|
||||
expect(sorted.map((n) => n.name)).toEqual(['alpha.ts', 'mid.ts', 'zebra.ts']);
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const nodes: TreeNode<TestItem>[] = [
|
||||
{ name: 'b.ts', fullPath: 'b.ts', isFile: true, children: [] },
|
||||
{ name: 'a.ts', fullPath: 'a.ts', isFile: true, children: [] },
|
||||
];
|
||||
|
||||
const sorted = sortTreeNodes(nodes);
|
||||
|
||||
expect(sorted).not.toBe(nodes);
|
||||
expect(nodes[0].name).toBe('b.ts');
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(sortTreeNodes([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
127
test/renderer/utils/tabLabelDisambiguation.test.ts
Normal file
127
test/renderer/utils/tabLabelDisambiguation.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* Tests for tab label disambiguation utility.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { computeDisambiguatedTabs } from '../../../src/renderer/utils/tabLabelDisambiguation';
|
||||
|
||||
import type { EditorFileTab } from '../../../src/shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function makeTab(filePath: string): EditorFileTab {
|
||||
const fileName = filePath.split('/').pop() ?? 'file';
|
||||
return {
|
||||
id: filePath,
|
||||
filePath,
|
||||
fileName,
|
||||
language: 'TypeScript',
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('computeDisambiguatedTabs', () => {
|
||||
it('returns tabs unchanged when all names are unique', () => {
|
||||
const tabs = [makeTab('/project/src/app.ts'), makeTab('/project/src/index.ts')];
|
||||
|
||||
const result = computeDisambiguatedTabs(tabs);
|
||||
|
||||
expect(result[0].disambiguatedLabel).toBeUndefined();
|
||||
expect(result[1].disambiguatedLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds labels for 2 tabs with the same file name', () => {
|
||||
const tabs = [
|
||||
makeTab('/project/src/main/utils/index.ts'),
|
||||
makeTab('/project/src/renderer/hooks/index.ts'),
|
||||
];
|
||||
|
||||
const result = computeDisambiguatedTabs(tabs);
|
||||
|
||||
expect(result[0].disambiguatedLabel).toBe('(utils)');
|
||||
expect(result[1].disambiguatedLabel).toBe('(hooks)');
|
||||
});
|
||||
|
||||
it('goes deeper when parent dirs also match', () => {
|
||||
const tabs = [
|
||||
makeTab('/project/src/main/utils/index.ts'),
|
||||
makeTab('/project/src/renderer/utils/index.ts'),
|
||||
];
|
||||
|
||||
const result = computeDisambiguatedTabs(tabs);
|
||||
|
||||
// Both have "utils" parent, need deeper suffix
|
||||
expect(result[0].disambiguatedLabel).toBe('(main/utils)');
|
||||
expect(result[1].disambiguatedLabel).toBe('(renderer/utils)');
|
||||
});
|
||||
|
||||
it('handles 3 tabs with the same name', () => {
|
||||
const tabs = [
|
||||
makeTab('/project/src/main/utils/index.ts'),
|
||||
makeTab('/project/src/renderer/utils/index.ts'),
|
||||
makeTab('/project/src/shared/utils/index.ts'),
|
||||
];
|
||||
|
||||
const result = computeDisambiguatedTabs(tabs);
|
||||
|
||||
expect(result[0].disambiguatedLabel).toBe('(main/utils)');
|
||||
expect(result[1].disambiguatedLabel).toBe('(renderer/utils)');
|
||||
expect(result[2].disambiguatedLabel).toBe('(shared/utils)');
|
||||
});
|
||||
|
||||
it('does not add labels for unique names among duplicates', () => {
|
||||
const tabs = [
|
||||
makeTab('/project/src/main/index.ts'),
|
||||
makeTab('/project/src/renderer/index.ts'),
|
||||
makeTab('/project/src/app.tsx'),
|
||||
];
|
||||
|
||||
const result = computeDisambiguatedTabs(tabs);
|
||||
|
||||
expect(result[0].disambiguatedLabel).toBe('(main)');
|
||||
expect(result[1].disambiguatedLabel).toBe('(renderer)');
|
||||
expect(result[2].disambiguatedLabel).toBeUndefined(); // unique name
|
||||
});
|
||||
|
||||
it('handles single tab (no disambiguation needed)', () => {
|
||||
const tabs = [makeTab('/project/src/index.ts')];
|
||||
|
||||
const result = computeDisambiguatedTabs(tabs);
|
||||
|
||||
expect(result[0].disambiguatedLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = computeDisambiguatedTabs([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears labels when tab is closed and names become unique', () => {
|
||||
// Start with 2 index.ts
|
||||
const tabs = [makeTab('/project/src/main/index.ts'), makeTab('/project/src/renderer/index.ts')];
|
||||
|
||||
const withLabels = computeDisambiguatedTabs(tabs);
|
||||
expect(withLabels[0].disambiguatedLabel).toBe('(main)');
|
||||
expect(withLabels[1].disambiguatedLabel).toBe('(renderer)');
|
||||
|
||||
// Close one — remaining should lose its label
|
||||
const afterClose = computeDisambiguatedTabs([withLabels[1]]);
|
||||
expect(afterClose[0].disambiguatedLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves tab reference when label unchanged', () => {
|
||||
const tab = makeTab('/project/src/app.ts');
|
||||
const tabs = [tab];
|
||||
|
||||
const result = computeDisambiguatedTabs(tabs);
|
||||
|
||||
// Same object reference (no unnecessary re-render)
|
||||
expect(result[0]).toBe(tab);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue