dsh-better-sidebar 0.10.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 +139 -0
- package/README_EN.md +139 -0
- package/lib/client-docx.js +7649 -0
- package/lib/client-editor.js +32677 -0
- package/lib/client-pptx.js +63438 -0
- package/lib/client-registry.js +8021 -0
- package/lib/client-terminal.js +8060 -0
- package/lib/client-xlsx.js +359755 -0
- package/lib/client.js +8021 -0
- package/lib/index.js +2604 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +220 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +13 -0
- package/lib/types/client/BrowserView.d.ts +22 -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 +10 -0
- package/lib/types/client/ExplorerView.d.ts +9 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/PptxView.d.ts +6 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +27 -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 +38 -0
- package/lib/types/client/TerminalView.d.ts +8 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/api.d.ts +170 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +17 -0
- package/lib/types/client/builtins/tabs.d.ts +6 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +74 -0
- package/lib/types/client/chunks/docx.d.ts +8 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/pptx.d.ts +8 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/chunks/xlsx.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/docx-view.d.ts +8 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/icons.d.ts +47 -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 +10 -0
- package/lib/types/client/intercept.d.ts +20 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +35 -0
- package/lib/types/client/locales.d.ts +237 -0
- package/lib/types/client/office-shared.d.ts +33 -0
- package/lib/types/client/office-types.d.ts +36 -0
- package/lib/types/client/office-view.d.ts +12 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +19 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/prefs.d.ts +30 -0
- package/lib/types/client/produced-files.d.ts +27 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +214 -0
- package/lib/types/client/split-pane.d.ts +29 -0
- package/lib/types/client/state.d.ts +322 -0
- package/lib/types/client/subagent-activity.d.ts +35 -0
- package/lib/types/client/subagent-detect.d.ts +47 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/theme.d.ts +28 -0
- package/lib/types/client/xlsx-to-univer.d.ts +33 -0
- package/lib/types/client/xlsx-view.d.ts +10 -0
- package/lib/types/config.d.ts +41 -0
- package/lib/types/context-types.d.ts +431 -0
- package/lib/types/fs-tree.d.ts +41 -0
- package/lib/types/git.d.ts +75 -0
- package/lib/types/html-route.d.ts +48 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/prefs-shared.d.ts +106 -0
- package/lib/types/pty-manager.d.ts +76 -0
- package/lib/types/tools.d.ts +28 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +168 -0
- package/src/agent-pty.ts +519 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +130 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +266 -0
- package/src/client/EditorHost.tsx +101 -0
- package/src/client/ExplorerView.tsx +283 -0
- package/src/client/GitView.tsx +540 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/PptxView.tsx +131 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +271 -0
- package/src/client/SideCardSection.tsx +426 -0
- package/src/client/Sidebar.tsx +861 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +866 -0
- package/src/client/TabBar.tsx +188 -0
- package/src/client/TerminalView.tsx +233 -0
- package/src/client/TextEditor.tsx +382 -0
- package/src/client/api.ts +215 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +33 -0
- package/src/client/builtins/tabs.tsx +211 -0
- package/src/client/builtins/viewers.tsx +151 -0
- package/src/client/chunk-loader.ts +196 -0
- package/src/client/chunks/docx.tsx +8 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/pptx.tsx +8 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/chunks/xlsx.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/docx-view.tsx +113 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/icons.tsx +137 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +238 -0
- package/src/client/intercept.tsx +94 -0
- package/src/client/lang.ts +95 -0
- package/src/client/layout.css +54 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +66 -0
- package/src/client/locales.ts +476 -0
- package/src/client/office-shared.tsx +40 -0
- package/src/client/office-types.ts +48 -0
- package/src/client/office-view.tsx +12 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +28 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/prefs.ts +100 -0
- package/src/client/produced-files.ts +87 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +434 -0
- package/src/client/sidebar.module.css +1917 -0
- package/src/client/split-pane.tsx +292 -0
- package/src/client/state.ts +1048 -0
- package/src/client/subagent-activity.ts +67 -0
- package/src/client/subagent-detect.ts +125 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/theme.ts +44 -0
- package/src/client/xlsx-to-univer.ts +260 -0
- package/src/client/xlsx-view.tsx +116 -0
- package/src/config.ts +95 -0
- package/src/context-types.ts +445 -0
- package/src/fs-tree.ts +115 -0
- package/src/git.ts +236 -0
- package/src/html-route.ts +70 -0
- package/src/index.ts +897 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/prefs-shared.ts +127 -0
- package/src/pty-manager.ts +199 -0
- package/src/tools.ts +477 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +94 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real diff surface for the git panel: parses the host's unified diff
|
|
3
|
+
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
|
|
4
|
+
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
|
|
5
|
+
* gutters, and aligned context / deleted / added rows colored through the
|
|
6
|
+
* DSH tokens. Untracked files produce no `git diff` output, so the caller
|
|
7
|
+
* can pass the file content to render as a full-file addition instead.
|
|
8
|
+
*
|
|
9
|
+
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
|
|
10
|
+
* cases are unit-tested without a DOM.
|
|
11
|
+
*/
|
|
12
|
+
import { useMemo, useState, type ReactNode } from 'react'
|
|
13
|
+
import clsx from 'clsx'
|
|
14
|
+
import { t } from './locales.ts'
|
|
15
|
+
import css from './sidebar.module.css'
|
|
16
|
+
|
|
17
|
+
/** One rendered diff line. */
|
|
18
|
+
export interface DiffLine {
|
|
19
|
+
kind: 'ctx' | 'del' | 'add' | 'meta'
|
|
20
|
+
/** The line content without its diff marker ('' for the no-newline marker). */
|
|
21
|
+
text: string
|
|
22
|
+
/** Old-side line number (null for pure additions / metadata). */
|
|
23
|
+
oldNum: number | null
|
|
24
|
+
/** New-side line number (null for pure deletions / metadata). */
|
|
25
|
+
newNum: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One parsed hunk. */
|
|
29
|
+
export interface DiffHunk {
|
|
30
|
+
/** The old-side start line (`-a[,b]`). */
|
|
31
|
+
oldStart: number
|
|
32
|
+
/** The new-side start line (`+c[,d]`). */
|
|
33
|
+
newStart: number
|
|
34
|
+
/** The section text after the trailing `@@` (may be empty). */
|
|
35
|
+
header: string
|
|
36
|
+
lines: DiffLine[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One parsed file section of a unified diff. */
|
|
40
|
+
export interface DiffFile {
|
|
41
|
+
/** The `---` path verbatim ('/dev/null' for a new file). */
|
|
42
|
+
oldPath: string
|
|
43
|
+
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
|
|
44
|
+
newPath: string
|
|
45
|
+
/** The file changed with binary content: no hunks to draw. */
|
|
46
|
+
binary: boolean
|
|
47
|
+
hunks: DiffHunk[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The parsed unified diff. */
|
|
51
|
+
export interface ParsedDiff {
|
|
52
|
+
files: DiffFile[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
|
|
56
|
+
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
|
|
57
|
+
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
|
|
58
|
+
if (match === null) return null
|
|
59
|
+
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse `git diff --no-color` output into file sections and hunks. Rows
|
|
64
|
+
* outside a file section (leading noise) and metadata rows between the
|
|
65
|
+
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
|
|
66
|
+
* changes, rename/similarity lines) are skipped; a section that never
|
|
67
|
+
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
|
|
68
|
+
* can still draw its path.
|
|
69
|
+
*/
|
|
70
|
+
export function parseUnifiedDiff(text: string): ParsedDiff {
|
|
71
|
+
const files: DiffFile[] = []
|
|
72
|
+
let current: DiffFile | null = null
|
|
73
|
+
let inHunk = false
|
|
74
|
+
let hunk: DiffHunk | null = null
|
|
75
|
+
let oldNum = 0
|
|
76
|
+
let newNum = 0
|
|
77
|
+
const flushHunk = (): void => {
|
|
78
|
+
if (current !== null && hunk !== null) current.hunks.push(hunk)
|
|
79
|
+
hunk = null
|
|
80
|
+
inHunk = false
|
|
81
|
+
}
|
|
82
|
+
for (const raw of text.split('\n')) {
|
|
83
|
+
if (raw.startsWith('diff --git ')) {
|
|
84
|
+
flushHunk()
|
|
85
|
+
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
|
|
86
|
+
files.push(current)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (current === null) continue
|
|
90
|
+
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
|
|
91
|
+
flushHunk()
|
|
92
|
+
current.binary = true
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (raw.startsWith('--- ')) {
|
|
96
|
+
flushHunk()
|
|
97
|
+
current.oldPath = raw.slice(4)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (raw.startsWith('+++ ')) {
|
|
101
|
+
current.newPath = raw.slice(4)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
const header = parseHunkHeader(raw)
|
|
105
|
+
if (header !== null) {
|
|
106
|
+
flushHunk()
|
|
107
|
+
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
|
|
108
|
+
oldNum = header.oldStart
|
|
109
|
+
newNum = header.newStart
|
|
110
|
+
inHunk = true
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (!inHunk || hunk === null) continue
|
|
114
|
+
const marker = raw[0]
|
|
115
|
+
if (marker === '\\') {
|
|
116
|
+
// ``: metadata attached to the previous row.
|
|
117
|
+
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (marker === ' ') {
|
|
121
|
+
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
|
|
122
|
+
oldNum += 1
|
|
123
|
+
newNum += 1
|
|
124
|
+
} else if (marker === '-') {
|
|
125
|
+
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
|
|
126
|
+
oldNum += 1
|
|
127
|
+
} else if (marker === '+') {
|
|
128
|
+
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
|
|
129
|
+
newNum += 1
|
|
130
|
+
} else {
|
|
131
|
+
// Not a diff line (a hunk can never contain one): stop the hunk.
|
|
132
|
+
flushHunk()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
flushHunk()
|
|
136
|
+
return { files }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Build the untracked-file shape: one file, one hunk of pure additions. */
|
|
140
|
+
function untrackedFile(path: string, content: string): DiffFile {
|
|
141
|
+
const lines: DiffLine[] = []
|
|
142
|
+
const body = content.endsWith('\n') ? content.slice(0, -1) : content
|
|
143
|
+
if (body !== '') {
|
|
144
|
+
let num = 1
|
|
145
|
+
for (const line of body.split('\n')) {
|
|
146
|
+
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
|
|
147
|
+
num += 1
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
|
|
154
|
+
function displayPath(path: string): string {
|
|
155
|
+
if (path === '/dev/null') return path
|
|
156
|
+
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
|
|
157
|
+
return path
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
|
|
161
|
+
function fileTag(file: DiffFile): string | null {
|
|
162
|
+
if (file.binary) return t('diffBinary')
|
|
163
|
+
if (file.oldPath === '/dev/null') return t('diffAdded')
|
|
164
|
+
if (file.newPath === '/dev/null') return t('diffDeleted')
|
|
165
|
+
const oldPath = displayPath(file.oldPath)
|
|
166
|
+
const newPath = displayPath(file.newPath)
|
|
167
|
+
if (oldPath !== newPath) return t('diffRenamed')
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
|
|
172
|
+
const MAX_DIFF_ROWS = 500
|
|
173
|
+
|
|
174
|
+
export interface DiffViewProps {
|
|
175
|
+
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
|
|
176
|
+
diff: string
|
|
177
|
+
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
|
178
|
+
untrackedPath?: string
|
|
179
|
+
untrackedContent?: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
|
|
183
|
+
const parsed = useMemo<ParsedDiff>(() => {
|
|
184
|
+
if (untrackedPath !== undefined) {
|
|
185
|
+
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
|
186
|
+
}
|
|
187
|
+
return parseUnifiedDiff(diff)
|
|
188
|
+
}, [diff, untrackedPath, untrackedContent])
|
|
189
|
+
const [expanded, setExpanded] = useState(false)
|
|
190
|
+
|
|
191
|
+
// Flatten into display rows so the cap can slice a single list.
|
|
192
|
+
const rows = useMemo(() => {
|
|
193
|
+
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
|
194
|
+
parsed.files.forEach((file, fileIndex) => {
|
|
195
|
+
out.push({ key: `f${fileIndex}`, file, type: 'path' })
|
|
196
|
+
if (file.binary) return
|
|
197
|
+
file.hunks.forEach((hunk, hunkIndex) => {
|
|
198
|
+
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
|
|
199
|
+
hunk.lines.forEach((line, lineIndex) => {
|
|
200
|
+
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
return out
|
|
205
|
+
}, [parsed])
|
|
206
|
+
|
|
207
|
+
const hidden = rows.length - MAX_DIFF_ROWS
|
|
208
|
+
const capped = hidden > 0 && !expanded
|
|
209
|
+
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
|
|
210
|
+
const tailLines = MAX_DIFF_ROWS - headLines
|
|
211
|
+
const head = capped ? rows.slice(0, headLines) : rows
|
|
212
|
+
const tail = capped ? rows.slice(rows.length - tailLines) : []
|
|
213
|
+
|
|
214
|
+
if (rows.length === 0) return null
|
|
215
|
+
|
|
216
|
+
const renderRow = (row: (typeof rows)[number]): ReactNode => {
|
|
217
|
+
if (row.type === 'path') {
|
|
218
|
+
const tag = fileTag(row.file)
|
|
219
|
+
const from = displayPath(row.file.oldPath)
|
|
220
|
+
const to = displayPath(row.file.newPath)
|
|
221
|
+
return (
|
|
222
|
+
<div key={row.key} className={css.gitDiffFile}>
|
|
223
|
+
<span className={css.gitDiffFilePath}>{to}</span>
|
|
224
|
+
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
|
|
225
|
+
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
if (row.type === 'hunk') {
|
|
230
|
+
const hunk = row.hunk!
|
|
231
|
+
return (
|
|
232
|
+
<div key={row.key} className={css.gitDiffHunk}>
|
|
233
|
+
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
|
|
234
|
+
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
const line = row.line!
|
|
239
|
+
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
|
|
240
|
+
return (
|
|
241
|
+
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
|
|
242
|
+
{line.kind === 'meta'
|
|
243
|
+
? <span className={css.gitDiffMetaText}>{line.text}</span>
|
|
244
|
+
: (
|
|
245
|
+
<>
|
|
246
|
+
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
|
|
247
|
+
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
|
|
248
|
+
<span className={css.gitDiffCode}>{line.text}</span>
|
|
249
|
+
</>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div className={css.gitDiff}>
|
|
257
|
+
{head.map(renderRow)}
|
|
258
|
+
{hidden > 0 && (
|
|
259
|
+
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
|
|
260
|
+
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
|
|
261
|
+
</button>
|
|
262
|
+
)}
|
|
263
|
+
{tail.map(renderRow)}
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editor tab host: resolves a file's previewer through the sidebar
|
|
3
|
+
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
|
|
4
|
+
* fetch strategy, and renders its component — or the shared download pane
|
|
5
|
+
* when nothing can render the file. The header shows the file title; the
|
|
6
|
+
* editable code/markdown viewers render their own toolbar below it.
|
|
7
|
+
*
|
|
8
|
+
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
|
9
|
+
* editor-load.ts); this component only wires it to the host APIs.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useState } from 'react'
|
|
12
|
+
import { createElement } from 'react'
|
|
13
|
+
import type { Context } from '../context-types.ts'
|
|
14
|
+
import { api, mediaUrl, type SessionScope } from './api.ts'
|
|
15
|
+
import { BinaryDownload } from './binary-download.tsx'
|
|
16
|
+
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
|
|
17
|
+
import { t } from './locales.ts'
|
|
18
|
+
import type { FileViewerDescriptor } from './service.ts'
|
|
19
|
+
import type { SidebarStore } from './state.ts'
|
|
20
|
+
import css from './sidebar.module.css'
|
|
21
|
+
|
|
22
|
+
type EditorLoad =
|
|
23
|
+
| { status: 'loading' }
|
|
24
|
+
| { status: 'error'; message: string }
|
|
25
|
+
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
|
|
26
|
+
| { status: 'binary' }
|
|
27
|
+
|
|
28
|
+
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
|
|
29
|
+
const { ctx, store, scope, path, title } = props
|
|
30
|
+
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let cancelled = false
|
|
34
|
+
setLoad({ status: 'loading' })
|
|
35
|
+
const mediaUrlOf = (): string => mediaUrl(scope, path)
|
|
36
|
+
const apply = (action: EditorLoadAction): void => {
|
|
37
|
+
if (cancelled) return
|
|
38
|
+
switch (action.kind) {
|
|
39
|
+
case 'binary':
|
|
40
|
+
setLoad({ status: 'binary' })
|
|
41
|
+
return
|
|
42
|
+
case 'render':
|
|
43
|
+
setLoad({
|
|
44
|
+
status: 'ready',
|
|
45
|
+
viewer: action.viewer,
|
|
46
|
+
content: action.content,
|
|
47
|
+
truncated: action.truncated,
|
|
48
|
+
mediaUrl: action.mediaUrl,
|
|
49
|
+
customData: action.customData,
|
|
50
|
+
})
|
|
51
|
+
return
|
|
52
|
+
case 'customLoad':
|
|
53
|
+
void action.viewer.load?.(path, scope).then((data) => {
|
|
54
|
+
if (cancelled) return
|
|
55
|
+
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
|
|
56
|
+
}).catch((error: unknown) => {
|
|
57
|
+
if (cancelled) return
|
|
58
|
+
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
59
|
+
})
|
|
60
|
+
return
|
|
61
|
+
case 'fetchFsRead':
|
|
62
|
+
api.fsRead(scope, path).then((result) => {
|
|
63
|
+
if (cancelled) return
|
|
64
|
+
// Binary reads carry the head bytes for the detect re-match.
|
|
65
|
+
const outcome = planFsReadOutcome(action.viewer, {
|
|
66
|
+
binary: result.kind === 'binary',
|
|
67
|
+
content: result.kind === 'text' ? result.content : '',
|
|
68
|
+
truncated: result.truncated,
|
|
69
|
+
head: result.kind === 'binary' ? result.head : undefined,
|
|
70
|
+
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
|
|
71
|
+
apply(outcome)
|
|
72
|
+
}).catch((error: unknown) => {
|
|
73
|
+
if (cancelled) return
|
|
74
|
+
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
75
|
+
})
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
|
80
|
+
return () => { cancelled = true }
|
|
81
|
+
}, [scope.sessionId, scope.cwd, path, ctx])
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={css.editor}>
|
|
85
|
+
<div className={css.editorHeader}>
|
|
86
|
+
<span className={css.editorTitle} title={path}>{title}</span>
|
|
87
|
+
</div>
|
|
88
|
+
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
|
89
|
+
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
|
90
|
+
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
|
|
91
|
+
{load.status === 'ready' && createElement(load.viewer.component, {
|
|
92
|
+
ctx, store, scope, path, title,
|
|
93
|
+
viewerId: load.viewer.id,
|
|
94
|
+
content: load.content,
|
|
95
|
+
truncated: load.truncated,
|
|
96
|
+
mediaUrl: load.mediaUrl,
|
|
97
|
+
customData: load.customData,
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file explorer: a lazy VSCode-style tree rooted at the session's
|
|
3
|
+
* working directory. Levels load on expansion (one API call per directory),
|
|
4
|
+
* directories sort first, hidden entries render dimmed, and the expansion
|
|
5
|
+
* set lives in the per-session state. Clicking a file opens an editor tab.
|
|
6
|
+
*
|
|
7
|
+
* Row actions: hovering a row reveals an @-reference button on the far
|
|
8
|
+
* right (appends `@<relative path>` to the composer draft), and right-click
|
|
9
|
+
* opens a context menu to copy the relative or absolute path (with a brief
|
|
10
|
+
* "copied" label replacing the button after a successful write); file rows
|
|
11
|
+
* also offer a download action (the host serves raw bytes, binary-safe).
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
|
14
|
+
import clsx from 'clsx'
|
|
15
|
+
import {
|
|
16
|
+
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
|
17
|
+
IconRefreshOutline16, Menu, writeClipboard,
|
|
18
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
19
|
+
import { api, downloadUrl, type FsEntry } from './api.ts'
|
|
20
|
+
import { relativeTo } from './paths.ts'
|
|
21
|
+
import { t } from './locales.ts'
|
|
22
|
+
import css from './sidebar.module.css'
|
|
23
|
+
|
|
24
|
+
interface LevelData {
|
|
25
|
+
entries?: FsEntry[]
|
|
26
|
+
error?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Root label: the last path segment (mirror of the host rootLabel). */
|
|
30
|
+
function baseName(path: string): string {
|
|
31
|
+
const trimmed = path.replace(/[\\/]+$/, '')
|
|
32
|
+
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
33
|
+
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How long the row's "copied" label stays after a successful write. */
|
|
37
|
+
const COPIED_MS = 1200
|
|
38
|
+
|
|
39
|
+
export function ExplorerView(props: {
|
|
40
|
+
sessionId: string
|
|
41
|
+
cwd: string | undefined
|
|
42
|
+
expanded: string[]
|
|
43
|
+
onToggle: (path: string) => void
|
|
44
|
+
onOpenFile: (path: string) => void
|
|
45
|
+
/** Insert `@<relative path>` into the composer draft. */
|
|
46
|
+
onReferenceFile: (path: string) => void
|
|
47
|
+
}) {
|
|
48
|
+
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
|
|
49
|
+
const [data, setData] = useState<Record<string, LevelData>>({})
|
|
50
|
+
const dataRef = useRef(data)
|
|
51
|
+
const [refreshTick, setRefreshTick] = useState(0)
|
|
52
|
+
/** The row whose path was just copied ("copied" label replaces its button). */
|
|
53
|
+
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
|
54
|
+
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
|
55
|
+
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
|
56
|
+
|
|
57
|
+
const storeLevel = useCallback((path: string, level: LevelData) => {
|
|
58
|
+
dataRef.current = { ...dataRef.current, [path]: level }
|
|
59
|
+
setData(dataRef.current)
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
const loadDir = useCallback((dir: string) => {
|
|
63
|
+
if (dataRef.current[dir] !== undefined) return
|
|
64
|
+
storeLevel(dir, {})
|
|
65
|
+
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
|
|
66
|
+
storeLevel(dir, { entries: listing.entries })
|
|
67
|
+
}).catch((error: unknown) => {
|
|
68
|
+
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
|
|
69
|
+
})
|
|
70
|
+
}, [sessionId, cwd, storeLevel])
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
// Load the visible set; already-loaded levels (kept in the cache) are
|
|
74
|
+
// not refetched. Only the refresh button wipes the cache.
|
|
75
|
+
const root = cwd
|
|
76
|
+
if (root === undefined) return
|
|
77
|
+
loadDir(root)
|
|
78
|
+
for (const dir of expanded) loadDir(dir)
|
|
79
|
+
}, [cwd, expanded, refreshTick, loadDir])
|
|
80
|
+
|
|
81
|
+
/** Copy `text`; on success flip the row's copied label for a moment. */
|
|
82
|
+
const copyPath = useCallback((text: string, path: string): void => {
|
|
83
|
+
void writeClipboard(text).then((ok) => {
|
|
84
|
+
if (!ok) return
|
|
85
|
+
setCopiedPath(path)
|
|
86
|
+
window.setTimeout(() => {
|
|
87
|
+
setCopiedPath(current => current === path ? null : current)
|
|
88
|
+
}, COPIED_MS)
|
|
89
|
+
})
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
/** The row's trailing actions: the @-reference button, or the copied label. */
|
|
93
|
+
const rowActions = (entry: FsEntry): ReactNode => {
|
|
94
|
+
if (copiedPath === entry.path) {
|
|
95
|
+
return <span className={css.explorerCopied}>{t('copied')}</span>
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className={css.explorerRef}
|
|
101
|
+
aria-label={t('referenceFile')}
|
|
102
|
+
title={t('referenceFile')}
|
|
103
|
+
onClick={(event) => {
|
|
104
|
+
event.stopPropagation()
|
|
105
|
+
onReferenceFile(entry.path)
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{t('referenceFile')}
|
|
109
|
+
</button>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
event.stopPropagation()
|
|
116
|
+
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Download a file through the host route (raw bytes, binary-safe). */
|
|
120
|
+
const downloadFile = (path: string): void => {
|
|
121
|
+
const url = downloadUrl({ sessionId, cwd }, path)
|
|
122
|
+
const anchor = document.createElement('a')
|
|
123
|
+
anchor.href = url
|
|
124
|
+
anchor.style.display = 'none'
|
|
125
|
+
document.body.appendChild(anchor)
|
|
126
|
+
anchor.click()
|
|
127
|
+
anchor.remove()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const root = cwd
|
|
131
|
+
|
|
132
|
+
const renderLevel = (dir: string, depth: number): ReactNode => {
|
|
133
|
+
const level = data[dir]
|
|
134
|
+
if (level === undefined) {
|
|
135
|
+
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
|
|
136
|
+
}
|
|
137
|
+
if (level.error !== undefined) {
|
|
138
|
+
return (
|
|
139
|
+
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
|
|
140
|
+
{level.error}
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
const entries = level.entries ?? []
|
|
145
|
+
return entries.map(entry => {
|
|
146
|
+
if (entry.isDir) {
|
|
147
|
+
const isOpen = expanded.includes(entry.path)
|
|
148
|
+
return (
|
|
149
|
+
<div key={entry.path}>
|
|
150
|
+
<div
|
|
151
|
+
role="button"
|
|
152
|
+
tabIndex={0}
|
|
153
|
+
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
|
154
|
+
style={{ paddingLeft: depth * 22 + 6 }}
|
|
155
|
+
onClick={() => { onToggle(entry.path) }}
|
|
156
|
+
onKeyDown={(event) => {
|
|
157
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
158
|
+
event.preventDefault()
|
|
159
|
+
onToggle(entry.path)
|
|
160
|
+
}
|
|
161
|
+
}}
|
|
162
|
+
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
|
163
|
+
>
|
|
164
|
+
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
|
165
|
+
<span className={css.explorerName}>{entry.name}</span>
|
|
166
|
+
{rowActions(entry)}
|
|
167
|
+
</div>
|
|
168
|
+
{isOpen && renderLevel(entry.path, depth + 1)}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return (
|
|
173
|
+
<div
|
|
174
|
+
key={entry.path}
|
|
175
|
+
role="button"
|
|
176
|
+
tabIndex={0}
|
|
177
|
+
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
|
|
178
|
+
style={{ paddingLeft: depth * 22 + 6 }}
|
|
179
|
+
title={entry.path}
|
|
180
|
+
onClick={() => { onOpenFile(entry.path) }}
|
|
181
|
+
onKeyDown={(event) => {
|
|
182
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
183
|
+
event.preventDefault()
|
|
184
|
+
onOpenFile(entry.path)
|
|
185
|
+
}
|
|
186
|
+
}}
|
|
187
|
+
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
|
188
|
+
>
|
|
189
|
+
<IconCodeOutline16 size={14} />
|
|
190
|
+
<span className={css.explorerName}>{entry.name}</span>
|
|
191
|
+
{rowActions(entry)}
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div className={css.explorer}>
|
|
199
|
+
<div className={css.explorerHeader}>
|
|
200
|
+
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className={css.iconButton}
|
|
204
|
+
aria-label={t('refresh')}
|
|
205
|
+
title={t('refresh')}
|
|
206
|
+
onClick={() => {
|
|
207
|
+
dataRef.current = {}
|
|
208
|
+
setData({})
|
|
209
|
+
setRefreshTick(tick => tick + 1)
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<IconRefreshOutline16 />
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
<div className={css.explorerBody}>
|
|
216
|
+
{root === undefined ? (
|
|
217
|
+
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
|
218
|
+
) : (
|
|
219
|
+
<>
|
|
220
|
+
<div
|
|
221
|
+
className={css.explorerRow}
|
|
222
|
+
style={{ paddingLeft: 6 }}
|
|
223
|
+
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
|
224
|
+
>
|
|
225
|
+
<IconFolderOpen16 size={14} />
|
|
226
|
+
<span className={css.explorerName}>{baseName(root)}</span>
|
|
227
|
+
{copiedPath === root
|
|
228
|
+
? <span className={css.explorerCopied}>{t('copied')}</span>
|
|
229
|
+
: (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
className={css.explorerRef}
|
|
233
|
+
aria-label={t('referenceFile')}
|
|
234
|
+
title={t('referenceFile')}
|
|
235
|
+
onClick={(event) => {
|
|
236
|
+
event.stopPropagation()
|
|
237
|
+
onReferenceFile(root)
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
{t('referenceFile')}
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
{data[root] !== undefined && renderLevel(root, 1)}
|
|
245
|
+
</>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
{/*
|
|
249
|
+
The one shared context menu, positioned at the right-click cursor
|
|
250
|
+
(portal so the explorer's overflow clip cannot crop it).
|
|
251
|
+
*/}
|
|
252
|
+
<Menu
|
|
253
|
+
open={rowMenu !== null}
|
|
254
|
+
onClose={() => { setRowMenu(null) }}
|
|
255
|
+
items={[
|
|
256
|
+
// Download applies to files only (the host route refuses directories).
|
|
257
|
+
...(rowMenu?.isDir === false
|
|
258
|
+
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
|
259
|
+
: []),
|
|
260
|
+
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
|
261
|
+
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
|
262
|
+
]}
|
|
263
|
+
onSelect={(id) => {
|
|
264
|
+
const target = rowMenu
|
|
265
|
+
if (target === null) return
|
|
266
|
+
setRowMenu(null)
|
|
267
|
+
if (id === 'download') {
|
|
268
|
+
downloadFile(target.path)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
copyPath(
|
|
272
|
+
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
|
273
|
+
target.path,
|
|
274
|
+
)
|
|
275
|
+
}}
|
|
276
|
+
portal
|
|
277
|
+
align="start"
|
|
278
|
+
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
|
279
|
+
anchor={<span />}
|
|
280
|
+
/>
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
}
|