dsh-coding-sidebar 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +48229 -0
- package/lib/client-mermaid.js +200745 -0
- package/lib/client-registry.js +21867 -0
- package/lib/client-terminal.js +9361 -0
- package/lib/client.js +21867 -0
- package/lib/index.js +4556 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +13 -0
- package/lib/types/client/FileTree.d.ts +38 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +10 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +54 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +24 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +275 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +131 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +59 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +25 -0
- package/lib/types/client/intercept.d.ts +46 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +452 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +38 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +85 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +439 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +36 -0
- package/lib/types/client/state.d.ts +432 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +605 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +124 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +20 -0
- package/lib/types/prefs-shared.d.ts +236 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +172 -0
- package/scripts/install.ps1 +306 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +558 -0
- package/src/client/FileTree.tsx +669 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +717 -0
- package/src/client/MarkdownHtml.tsx +296 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1163 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +645 -0
- package/src/client/Sidebar.tsx +1370 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +275 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +357 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +340 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +358 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +152 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +427 -0
- package/src/client/intercept.tsx +188 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +24 -0
- package/src/client/layout.css +90 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +368 -0
- package/src/client/locales-de.ts +353 -0
- package/src/client/locales-fr.ts +360 -0
- package/src/client/locales-hi.ts +367 -0
- package/src/client/locales-id.ts +365 -0
- package/src/client/locales-it.ts +358 -0
- package/src/client/locales-ja.ts +367 -0
- package/src/client/locales-ko.ts +359 -0
- package/src/client/locales-nl.ts +365 -0
- package/src/client/locales-pl.ts +369 -0
- package/src/client/locales-pt.ts +350 -0
- package/src/client/locales-ru.ts +365 -0
- package/src/client/locales-sv.ts +350 -0
- package/src/client/locales-th.ts +367 -0
- package/src/client/locales-tr.ts +367 -0
- package/src/client/locales-vi.ts +367 -0
- package/src/client/locales-zh-HK.ts +382 -0
- package/src/client/locales-zh-MO.ts +382 -0
- package/src/client/locales-zh-TW.ts +382 -0
- package/src/client/locales.ts +894 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +52 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +143 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +183 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +96 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +210 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +859 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2788 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +331 -0
- package/src/client/state.ts +1497 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +154 -0
- package/src/context-types.ts +611 -0
- package/src/fs-operations.ts +95 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1291 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +75 -0
- package/src/prefs-shared.ts +280 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/wire.ts +100 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git operations for the sidebar source-control panel. Everything goes
|
|
3
|
+
* through the system `git` binary spawned per request (no library, no state),
|
|
4
|
+
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
|
|
5
|
+
* so parsing never depends on locale or color config. All commands run with
|
|
6
|
+
* `-C <cwd>` on the session's working directory and `--no-pager` /
|
|
7
|
+
* `-c color.ui=false` so output stays machine-readable.
|
|
8
|
+
*
|
|
9
|
+
* Commits use the user's git global identity untouched (never sets
|
|
10
|
+
* user.name/user.email).
|
|
11
|
+
*/
|
|
12
|
+
import { readdir } from 'node:fs/promises'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { resolve } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** A parsed `git status --porcelain=v1 -z` entry. */
|
|
18
|
+
export interface GitStatusEntry {
|
|
19
|
+
path: string
|
|
20
|
+
/** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
|
|
21
|
+
xy: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The source-control panel snapshot. */
|
|
25
|
+
export interface GitStatusResult {
|
|
26
|
+
isRepo: boolean
|
|
27
|
+
branch?: string
|
|
28
|
+
entries: GitStatusEntry[]
|
|
29
|
+
/** True when the working tree had more rows than `GIT_STATUS_LIMIT`; the
|
|
30
|
+
* panel shows a truncation notice instead of freezing on a huge untracked
|
|
31
|
+
* set (issue #369). */
|
|
32
|
+
truncated?: boolean
|
|
33
|
+
/** Selected repository root, or the discovered roots when the cwd is a container. */
|
|
34
|
+
root?: string
|
|
35
|
+
repositories?: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One linked checkout returned by `git worktree list --porcelain`. */
|
|
39
|
+
export interface GitWorktree {
|
|
40
|
+
/** Absolute checkout root. */
|
|
41
|
+
path: string
|
|
42
|
+
/** Branch name without `refs/heads/`, or `HEAD` when detached. */
|
|
43
|
+
branch: string
|
|
44
|
+
/** Whether this checkout contains the session cwd. */
|
|
45
|
+
current: boolean
|
|
46
|
+
/** Number of staged + unstaged status rows (a file changed on both sides counts once). */
|
|
47
|
+
changes: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One `git log` row. */
|
|
51
|
+
export interface GitLogEntry {
|
|
52
|
+
/** Short hash (7+ chars, display). */
|
|
53
|
+
hash: string
|
|
54
|
+
/** Full 40-char hash (advanced operations: revert / cherry-pick). */
|
|
55
|
+
hashFull: string
|
|
56
|
+
subject: string
|
|
57
|
+
author: string
|
|
58
|
+
/** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
|
|
59
|
+
date: string
|
|
60
|
+
/** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
|
|
61
|
+
refs: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One git failure (stderr text as the message). */
|
|
65
|
+
export class GitCommandError extends Error {
|
|
66
|
+
constructor(
|
|
67
|
+
message: string,
|
|
68
|
+
readonly code = 'git-error',
|
|
69
|
+
readonly command: string,
|
|
70
|
+
) {
|
|
71
|
+
super(message)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
|
|
76
|
+
export function parsePorcelainZ(output: string): GitStatusEntry[] {
|
|
77
|
+
const tokens = output.split('\0')
|
|
78
|
+
const entries: GitStatusEntry[] = []
|
|
79
|
+
let index = 0
|
|
80
|
+
while (index < tokens.length) {
|
|
81
|
+
const token = tokens[index]!
|
|
82
|
+
index += 1
|
|
83
|
+
if (token === '') continue
|
|
84
|
+
const xy = token.slice(0, 2)
|
|
85
|
+
const rest = token.slice(3)
|
|
86
|
+
entries.push({ path: rest, xy })
|
|
87
|
+
// Rename/copy entries carry the ORIGIN path as the next NUL field; the
|
|
88
|
+
// new path (the file as it exists now) is the display path.
|
|
89
|
+
if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
|
|
90
|
+
index += 1
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return entries
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One raw porcelain worktree record. Prunable checkouts are retained by
|
|
97
|
+
* Git's administrative metadata after their directory disappears and must not
|
|
98
|
+
* become selectable command targets. Locked checkouts remain usable. */
|
|
99
|
+
export interface GitWorktreeRecord {
|
|
100
|
+
path: string
|
|
101
|
+
branch: string
|
|
102
|
+
locked: boolean
|
|
103
|
+
prunable: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Parse `git worktree list --porcelain` records. Production requests use
|
|
107
|
+
* `-z` so even newlines and non-ASCII bytes in checkout paths stay lossless;
|
|
108
|
+
* newline framing remains accepted for small fixtures and older Git output. */
|
|
109
|
+
export function parseWorktreeList(output: string): GitWorktreeRecord[] {
|
|
110
|
+
const rows: GitWorktreeRecord[] = []
|
|
111
|
+
let path: string | undefined
|
|
112
|
+
let branch = 'HEAD'
|
|
113
|
+
let locked = false
|
|
114
|
+
let prunable = false
|
|
115
|
+
const flush = (): void => {
|
|
116
|
+
if (path !== undefined) rows.push({ path, branch, locked, prunable })
|
|
117
|
+
path = undefined
|
|
118
|
+
branch = 'HEAD'
|
|
119
|
+
locked = false
|
|
120
|
+
prunable = false
|
|
121
|
+
}
|
|
122
|
+
const sep = output.includes('\0') ? '\0' : '\n'
|
|
123
|
+
const framed = output.endsWith(sep) ? output : `${output}${sep}`
|
|
124
|
+
for (const line of framed.split(sep)) {
|
|
125
|
+
if (line === '') {
|
|
126
|
+
flush()
|
|
127
|
+
} else if (line.startsWith('worktree ')) {
|
|
128
|
+
path = line.slice('worktree '.length)
|
|
129
|
+
} else if (line.startsWith('branch refs/heads/')) {
|
|
130
|
+
branch = line.slice('branch refs/heads/'.length)
|
|
131
|
+
} else if (line === 'locked' || line.startsWith('locked ')) {
|
|
132
|
+
locked = true
|
|
133
|
+
} else if (line === 'prunable' || line.startsWith('prunable ')) {
|
|
134
|
+
prunable = true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return rows
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
|
|
141
|
+
export function parseLogLines(output: string): GitLogEntry[] {
|
|
142
|
+
const rows: GitLogEntry[] = []
|
|
143
|
+
for (const line of output.split('\n')) {
|
|
144
|
+
if (line === '') continue
|
|
145
|
+
const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
|
|
146
|
+
if (hash === undefined || subject === undefined) continue
|
|
147
|
+
rows.push({
|
|
148
|
+
hash,
|
|
149
|
+
subject,
|
|
150
|
+
author: author ?? '',
|
|
151
|
+
date: date ?? '',
|
|
152
|
+
hashFull: hashFull ?? hash,
|
|
153
|
+
refs: refs ?? '',
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
return rows
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
|
|
160
|
+
function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
|
|
161
|
+
const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
|
|
162
|
+
return new Promise<string>((resolvePromise, reject) => {
|
|
163
|
+
const child = spawn('git', full, {
|
|
164
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
165
|
+
windowsHide: true,
|
|
166
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
167
|
+
})
|
|
168
|
+
let stdout = ''
|
|
169
|
+
let stderr = ''
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
child.kill('SIGKILL')
|
|
172
|
+
reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
|
|
173
|
+
}, timeoutMs)
|
|
174
|
+
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
|
175
|
+
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
|
176
|
+
child.on('error', (error) => {
|
|
177
|
+
clearTimeout(timer)
|
|
178
|
+
reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
|
|
179
|
+
})
|
|
180
|
+
child.on('close', (code) => {
|
|
181
|
+
clearTimeout(timer)
|
|
182
|
+
if (code === 0) {
|
|
183
|
+
resolvePromise(stdout)
|
|
184
|
+
} else {
|
|
185
|
+
reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Cap on child directories probed by the workspace-container fallback scan.
|
|
192
|
+
* A home-directory cwd can hold hundreds of visible folders (Library, iCloud
|
|
193
|
+
* mounts…); probing them all serially is what froze the panel in #369. */
|
|
194
|
+
const DISCOVERY_LIMIT = 200
|
|
195
|
+
/** Per-probe and direct-discovery budget. `rev-parse` is millisecond-scale on
|
|
196
|
+
* a healthy checkout; a probe that needs longer is a stalled mount and is
|
|
197
|
+
* better abandoned than waited on. */
|
|
198
|
+
const DISCOVERY_TIMEOUT_MS = 5_000
|
|
199
|
+
/** Discovery results are cheap to recompute but expensive to storm: the panel
|
|
200
|
+
* polls every 2s and each poll fans out into several git.* calls that all
|
|
201
|
+
* resolve the same roots. A short TTL keeps fan-out at one scan per cwd. */
|
|
202
|
+
const DISCOVERY_CACHE_TTL_MS = 60_000
|
|
203
|
+
|
|
204
|
+
const repoRootsCache = new Map<string, { roots: string[]; expires: number }>()
|
|
205
|
+
const repoRootsInFlight = new Map<string, Promise<string[]>>()
|
|
206
|
+
|
|
207
|
+
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`).
|
|
208
|
+
* Probe timeout is short: a cwd on a stalled mount must not hold the panel
|
|
209
|
+
* hostage for the full command budget (issue #369). */
|
|
210
|
+
export async function isGitRepo(cwd: string): Promise<boolean> {
|
|
211
|
+
try {
|
|
212
|
+
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'], DISCOVERY_TIMEOUT_MS)
|
|
213
|
+
return out.trim() === 'true'
|
|
214
|
+
} catch {
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
|
|
220
|
+
async function directRepoRoot(cwd: string): Promise<string> {
|
|
221
|
+
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'], DISCOVERY_TIMEOUT_MS)
|
|
222
|
+
return out.trim()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Discover the current repository or direct child repositories. Results are
|
|
226
|
+
* cached per cwd and concurrent callers share one in-flight scan, so opening
|
|
227
|
+
* the panel (three parallel git.* requests) costs a single discovery pass. */
|
|
228
|
+
export function repoRoots(cwd: string): Promise<string[]> {
|
|
229
|
+
const cached = repoRootsCache.get(cwd)
|
|
230
|
+
if (cached !== undefined && cached.expires > Date.now()) return Promise.resolve(cached.roots)
|
|
231
|
+
const pending = repoRootsInFlight.get(cwd)
|
|
232
|
+
if (pending !== undefined) return pending
|
|
233
|
+
const promise = discoverRepoRoots(cwd).then(
|
|
234
|
+
(roots) => {
|
|
235
|
+
repoRootsCache.set(cwd, { roots, expires: Date.now() + DISCOVERY_CACHE_TTL_MS })
|
|
236
|
+
repoRootsInFlight.delete(cwd)
|
|
237
|
+
return roots
|
|
238
|
+
},
|
|
239
|
+
(error: unknown) => {
|
|
240
|
+
repoRootsInFlight.delete(cwd)
|
|
241
|
+
throw error
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
repoRootsInFlight.set(cwd, promise)
|
|
245
|
+
return promise
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function discoverRepoRoots(cwd: string): Promise<string[]> {
|
|
249
|
+
try {
|
|
250
|
+
return [await directRepoRoot(cwd)]
|
|
251
|
+
} catch {
|
|
252
|
+
const entries = await readdir(cwd, { withFileTypes: true }).catch(() => [])
|
|
253
|
+
const roots: string[] = []
|
|
254
|
+
for (const entry of entries
|
|
255
|
+
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules')
|
|
256
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
257
|
+
.slice(0, DISCOVERY_LIMIT)) {
|
|
258
|
+
try {
|
|
259
|
+
const root = await directRepoRoot(join(cwd, entry.name))
|
|
260
|
+
if (!roots.some(existing => pathIdentity(existing) === pathIdentity(root))) roots.push(root)
|
|
261
|
+
} catch {
|
|
262
|
+
// Ordinary child directory; keep discovering sibling repositories.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return roots
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Resolve the selected repository, defaulting to the first discovered root. */
|
|
270
|
+
export async function repoRoot(cwd: string, selected?: string): Promise<string> {
|
|
271
|
+
const roots = await repoRoots(cwd)
|
|
272
|
+
if (roots.length === 0) throw new GitCommandError('not a git repository', 'not-repo', 'rev-parse')
|
|
273
|
+
// Git for Windows may return forward-slash roots while callers pass
|
|
274
|
+
// backslashes (or vice-versa); compare via the platform-aware identity.
|
|
275
|
+
if (selected !== undefined) {
|
|
276
|
+
const identity = pathIdentity(selected)
|
|
277
|
+
const match = roots.find(root => pathIdentity(root) === identity)
|
|
278
|
+
if (match !== undefined) return match
|
|
279
|
+
}
|
|
280
|
+
return roots[0]!
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
|
|
284
|
+
export async function currentBranch(cwd: string): Promise<string> {
|
|
285
|
+
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
286
|
+
return out.trim()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Upper bound on status rows shipped to the client. Beyond this the result
|
|
290
|
+
* is truncated (with `truncated: true`) so a pathological untracked set —
|
|
291
|
+
* e.g. the working tree discovered under a home-directory cwd — cannot
|
|
292
|
+
* freeze the browser main thread on JSON parse or list render (#369). */
|
|
293
|
+
const GIT_STATUS_LIMIT = 2_000
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Working-tree status (untracked included). `--untracked-files=all` lists
|
|
297
|
+
* the contents of new directories as individual entries, while preserving
|
|
298
|
+
* repository discovery and explicit repository selection for workspace roots.
|
|
299
|
+
*/
|
|
300
|
+
export async function status(cwd: string, selected?: string): Promise<GitStatusResult> {
|
|
301
|
+
const repositories = await repoRoots(cwd)
|
|
302
|
+
if (repositories.length === 0) return { isRepo: false, entries: [], repositories: [] }
|
|
303
|
+
const root = await repoRoot(cwd, selected)
|
|
304
|
+
const [branch, raw] = await Promise.all([
|
|
305
|
+
currentBranch(root).catch(() => 'HEAD'),
|
|
306
|
+
runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
|
|
307
|
+
])
|
|
308
|
+
const parsed = parsePorcelainZ(raw)
|
|
309
|
+
const truncated = parsed.length > GIT_STATUS_LIMIT
|
|
310
|
+
return {
|
|
311
|
+
isRepo: true,
|
|
312
|
+
branch,
|
|
313
|
+
entries: truncated ? parsed.slice(0, GIT_STATUS_LIMIT) : parsed,
|
|
314
|
+
truncated,
|
|
315
|
+
root,
|
|
316
|
+
repositories,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Platform-aware identity used only for comparing absolute checkout roots. */
|
|
321
|
+
function pathIdentity(path: string): string {
|
|
322
|
+
const absolute = resolve(path).replace(/[\\/]+$/, '')
|
|
323
|
+
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Raw usable checkout records, shared by inventory and target validation.
|
|
327
|
+
* Prunable records point at missing paths and are deliberately excluded from
|
|
328
|
+
* both the selector and the command-target allowlist. */
|
|
329
|
+
async function listedWorktrees(cwd: string): Promise<GitWorktreeRecord[]> {
|
|
330
|
+
const raw = await runGit(cwd, ['worktree', 'list', '--porcelain', '-z'])
|
|
331
|
+
return parseWorktreeList(raw).filter(entry => !entry.prunable)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** All linked checkouts of the repository containing `cwd`, enriched with a
|
|
335
|
+
* live change count. The current checkout is first so a single-worktree repo
|
|
336
|
+
* preserves the old UI ordering. */
|
|
337
|
+
export async function worktrees(cwd: string): Promise<GitWorktree[]> {
|
|
338
|
+
if (!await isGitRepo(cwd)) return []
|
|
339
|
+
const currentRoot = await repoRoot(cwd)
|
|
340
|
+
const listed = await listedWorktrees(cwd)
|
|
341
|
+
const rows = await Promise.all(listed.map(async (entry): Promise<GitWorktree> => ({
|
|
342
|
+
path: entry.path,
|
|
343
|
+
branch: entry.branch,
|
|
344
|
+
current: pathIdentity(entry.path) === pathIdentity(currentRoot),
|
|
345
|
+
// One stale/permission-raced linked checkout must not hide the valid
|
|
346
|
+
// current repository from the panel. Targeted operations still fail loud.
|
|
347
|
+
changes: await status(entry.path).then(result => result.entries.length, () => 0),
|
|
348
|
+
})))
|
|
349
|
+
return rows.sort((left, right) => Number(right.current) - Number(left.current))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Resolve an optional client-selected linked checkout. A caller may never use
|
|
353
|
+
* this seam to point Git operations at an unrelated repository: the target
|
|
354
|
+
* must occur in the authoritative session repository's worktree list. */
|
|
355
|
+
export async function resolveWorktree(cwd: string, requested?: string): Promise<string> {
|
|
356
|
+
if (requested === undefined || requested === '') return cwd
|
|
357
|
+
const identity = pathIdentity(requested)
|
|
358
|
+
const match = (await listedWorktrees(cwd)).find(entry => pathIdentity(entry.path) === identity)
|
|
359
|
+
if (match === undefined) {
|
|
360
|
+
throw new GitCommandError(`unknown linked worktree: ${requested}`, 'git-worktree', 'worktree list')
|
|
361
|
+
}
|
|
362
|
+
return match.path
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Diff text of the worktree (unstaged) or the index (staged). */
|
|
366
|
+
export async function diff(cwd: string, path: string | undefined, staged: boolean, selected?: string): Promise<string> {
|
|
367
|
+
const root = await repoRoot(cwd, selected)
|
|
368
|
+
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
|
|
369
|
+
if (staged) args.push('--cached')
|
|
370
|
+
if (path !== undefined) args.push('--', path)
|
|
371
|
+
return runGit(root, args)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Stage paths (all when path is undefined). */
|
|
375
|
+
export async function stage(cwd: string, path: string | undefined, selected?: string): Promise<void> {
|
|
376
|
+
await runGit(await repoRoot(cwd, selected), ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Unstage paths (all when path is undefined). */
|
|
380
|
+
export async function unstage(cwd: string, path: string | undefined, selected?: string): Promise<void> {
|
|
381
|
+
await runGit(await repoRoot(cwd, selected), ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Commit the staged changes with a message (global identity untouched). */
|
|
385
|
+
export async function commit(cwd: string, message: string, selected?: string): Promise<void> {
|
|
386
|
+
await runGit(await repoRoot(cwd, selected), ['commit', '-m', message])
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Branch names (current first). */
|
|
390
|
+
export async function branches(cwd: string, selected?: string): Promise<{ current: string; names: string[] }> {
|
|
391
|
+
const root = await repoRoot(cwd, selected)
|
|
392
|
+
const [current, raw] = await Promise.all([
|
|
393
|
+
currentBranch(root).catch(() => 'HEAD'),
|
|
394
|
+
runGit(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
|
|
395
|
+
])
|
|
396
|
+
const names = raw.split('\n').filter(line => line !== '')
|
|
397
|
+
return { current, names: names.includes(current) ? names : [current, ...names] }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Switch to an existing branch. */
|
|
401
|
+
export async function checkout(cwd: string, branch: string, selected?: string): Promise<void> {
|
|
402
|
+
await runGit(await repoRoot(cwd, selected), ['checkout', branch])
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Recent commit history (newest first), lazily pageable via skip/count. */
|
|
406
|
+
export async function log(cwd: string, count = 30, skip = 0, selected?: string): Promise<GitLogEntry[]> {
|
|
407
|
+
const raw = await runGit(await repoRoot(cwd, selected), [
|
|
408
|
+
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
|
|
409
|
+
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
|
|
410
|
+
])
|
|
411
|
+
return parseLogLines(raw)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
|
|
416
|
+
* revision has no such path (a new/untracked file has no HEAD side).
|
|
417
|
+
*/
|
|
418
|
+
export async function show(cwd: string, rev: string, path: string, selected?: string): Promise<string | null> {
|
|
419
|
+
try {
|
|
420
|
+
return await runGit(await repoRoot(cwd, selected), ['show', `${rev}:${path}`])
|
|
421
|
+
} catch {
|
|
422
|
+
return null
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
|
427
|
+
* Merge commits show their diff against the first parent (`-m --first-parent`
|
|
428
|
+
* is a no-op for regular commits), so a history click always has content. */
|
|
429
|
+
export async function commitDiff(cwd: string, hash: string, selected?: string): Promise<string> {
|
|
430
|
+
return runGit(await repoRoot(cwd, selected), ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
|
|
434
|
+
export async function discard(cwd: string, path: string, selected?: string): Promise<void> {
|
|
435
|
+
await runGit(await repoRoot(cwd, selected), ['checkout', '--', path])
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Revert one commit onto the current branch with an auto-generated message. */
|
|
439
|
+
export async function revert(cwd: string, hash: string, selected?: string): Promise<void> {
|
|
440
|
+
await runGit(await repoRoot(cwd, selected), ['revert', '--no-edit', hash])
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Cherry-pick one commit onto the current branch. */
|
|
444
|
+
export async function cherryPick(cwd: string, hash: string, selected?: string): Promise<void> {
|
|
445
|
+
await runGit(await repoRoot(cwd, selected), ['cherry-pick', hash])
|
|
446
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URL vocabulary of the /sidebar/html route (HTML previewer).
|
|
3
|
+
*
|
|
4
|
+
* Why path-encoded parameters instead of a query string: the previewed
|
|
5
|
+
* page resolves its relative assets (./style.css, img/x.png) against the
|
|
6
|
+
* document URL, and the WHATWG URL algorithm DROPS the query of a
|
|
7
|
+
* path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css`
|
|
8
|
+
* would lose the session scope and the route would reject the asset.
|
|
9
|
+
* Encoding everything into the URL path keeps relative resolution inside
|
|
10
|
+
* the same route with every request self-contained:
|
|
11
|
+
*
|
|
12
|
+
* /sidebar/html/<sessionId>/<absolute-path segments, encodeURIComponent'd>
|
|
13
|
+
* /sidebar/html/S/Users/me/proj/index.html
|
|
14
|
+
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
|
|
15
|
+
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
|
|
16
|
+
* UNC (\\server\share\... or //server/share/...):
|
|
17
|
+
* → /sidebar/html/S//server/share/proj/a.html ('//' right after the
|
|
18
|
+
* sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
|
|
19
|
+
* relative assets still resolve inside the same route)
|
|
20
|
+
*
|
|
21
|
+
* The decoder rebuilds the marker as a forward-slash `//server/share/...`
|
|
22
|
+
* path. That form is intentionally platform-neutral: `node:path` resolves it
|
|
23
|
+
* to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
|
|
24
|
+
* host's existing requireAbsolute + isWithin fence needs no platform signal
|
|
25
|
+
* (a leading `//` is a legal POSIX absolute path, so no data is lost on
|
|
26
|
+
* either platform).
|
|
27
|
+
*
|
|
28
|
+
* This module is intentionally dependency-free (no node imports, no wire
|
|
29
|
+
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
|
|
30
|
+
* the build-time purity gate; the host converts decode failures into
|
|
31
|
+
* SidebarError responses at the route boundary.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** One decoded route reference. */
|
|
35
|
+
export interface HtmlRouteRef {
|
|
36
|
+
sessionId: string
|
|
37
|
+
/** Absolute file path (leading slash; Windows drives keep their colon). */
|
|
38
|
+
path: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Decode outcome: the reference, or a client-error description. */
|
|
42
|
+
export type HtmlDecodeResult =
|
|
43
|
+
| { ok: true; ref: HtmlRouteRef }
|
|
44
|
+
| { ok: false; status: 400 | 404; message: string }
|
|
45
|
+
|
|
46
|
+
/** The route prefix both encoders/decoders agree on. */
|
|
47
|
+
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
|
|
48
|
+
|
|
49
|
+
/** Build the route URL for one absolute file path (client + tests). */
|
|
50
|
+
export function encodeHtmlUrl(sessionId: string, path: string): string {
|
|
51
|
+
const unc = /^[\\/]{2}[^\\/]/.test(path)
|
|
52
|
+
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
|
|
53
|
+
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decode a route pathname into the session + absolute file path. Rejects
|
|
58
|
+
* a wrong prefix (404), an empty path, malformed percent encoding, and a
|
|
59
|
+
* missing sessionId or file path (400). The caller still must bound the
|
|
60
|
+
* decoded path with the workspace real-path guard — a decoded `..`
|
|
61
|
+
* segment resolves outside the cwd and is refused there.
|
|
62
|
+
*/
|
|
63
|
+
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
|
64
|
+
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
|
|
65
|
+
return { ok: false, status: 404, message: 'not an html route' }
|
|
66
|
+
}
|
|
67
|
+
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
|
|
68
|
+
if (rest === '') {
|
|
69
|
+
return { ok: false, status: 400, message: 'invalid html route path' }
|
|
70
|
+
}
|
|
71
|
+
let segments: string[]
|
|
72
|
+
try {
|
|
73
|
+
segments = rest.split('/').map(segment => decodeURIComponent(segment))
|
|
74
|
+
} catch {
|
|
75
|
+
return { ok: false, status: 400, message: 'malformed URL encoding' }
|
|
76
|
+
}
|
|
77
|
+
const [sessionId, ...pathSegments] = segments
|
|
78
|
+
if (sessionId === undefined || sessionId === '') {
|
|
79
|
+
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
|
80
|
+
}
|
|
81
|
+
// An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
|
|
82
|
+
// '<sid>//server/share/...' for UNC paths); the encoder filters empty
|
|
83
|
+
// segments everywhere else, so an empty segment can only be the marker or
|
|
84
|
+
// a malformed URL — both handled here.
|
|
85
|
+
const unc = pathSegments[0] === ''
|
|
86
|
+
const tail = unc ? pathSegments.slice(1) : pathSegments
|
|
87
|
+
if (tail.length === 0 || tail.some(segment => segment === '')) {
|
|
88
|
+
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
|
89
|
+
}
|
|
90
|
+
let path: string
|
|
91
|
+
if (unc) {
|
|
92
|
+
// Rebuild the platform-neutral forward-slash form `//server/share/...`;
|
|
93
|
+
// requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
|
|
94
|
+
path = `//${tail.join('/')}`
|
|
95
|
+
} else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
|
|
96
|
+
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
|
|
97
|
+
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
|
|
98
|
+
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
|
|
99
|
+
// the html route's workspace fence would then reject every drive path.
|
|
100
|
+
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
|
|
101
|
+
path = tail.join('/')
|
|
102
|
+
} else {
|
|
103
|
+
path = `/${tail.join('/')}`
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, ref: { sessionId, path } }
|
|
106
|
+
}
|