dsh-coding-sidebar 1.0.7 → 1.0.9
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/README.md +2 -2
- package/lib/client-editor.js +485 -228
- package/lib/client-registry.js +1238 -7583
- package/lib/client-terminal.js +345 -189
- package/lib/client.js +1213 -7558
- package/lib/index.js +518 -69
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/DiffView.d.ts +33 -1
- package/lib/types/client/EditorHost.d.ts +4 -1
- package/lib/types/client/FileTree.d.ts +6 -2
- package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
- package/lib/types/client/TreePanel.d.ts +4 -1
- package/lib/types/client/api.d.ts +26 -0
- package/lib/types/client/chunk-loader.d.ts +1 -1
- package/lib/types/client/chunks/locale.d.ts +3 -0
- package/lib/types/client/conversation-draft.d.ts +85 -4
- package/lib/types/client/locales.d.ts +13 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/state.d.ts +15 -0
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/fs-operations.d.ts +42 -0
- package/lib/types/git.d.ts +15 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/prefs-shared.d.ts +7 -5
- package/lib/types/pty-manager.d.ts +53 -0
- package/lib/types/wire.d.ts +6 -2
- package/package.json +1 -1
- package/src/agent-pty.ts +221 -33
- package/src/bundle-route.ts +1 -1
- package/src/client/DiffTab.tsx +10 -1
- package/src/client/DiffView.tsx +174 -19
- package/src/client/EditorHost.tsx +9 -2
- package/src/client/FileTree.tsx +151 -8
- package/src/client/Sidebar.tsx +95 -25
- package/src/client/TerminalView.tsx +41 -0
- package/src/client/TerminalWaitBanner.tsx +32 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +22 -2
- package/src/client/api.ts +20 -0
- package/src/client/chunk-loader.ts +1 -1
- package/src/client/chunks/locale.tsx +60 -0
- package/src/client/conversation-draft.ts +233 -7
- package/src/client/index.tsx +19 -8
- package/src/client/locales-ar.ts +17 -4
- package/src/client/locales-de.ts +17 -4
- package/src/client/locales-fr.ts +17 -4
- package/src/client/locales-hi.ts +17 -4
- package/src/client/locales-id.ts +17 -4
- package/src/client/locales-it.ts +17 -4
- package/src/client/locales-ja.ts +17 -4
- package/src/client/locales-ko.ts +17 -4
- package/src/client/locales-nl.ts +17 -4
- package/src/client/locales-pl.ts +17 -4
- package/src/client/locales-pt.ts +17 -4
- package/src/client/locales-ru.ts +17 -4
- package/src/client/locales-sv.ts +17 -4
- package/src/client/locales-th.ts +17 -4
- package/src/client/locales-tr.ts +17 -4
- package/src/client/locales-vi.ts +17 -4
- package/src/client/locales-zh-HK.ts +17 -4
- package/src/client/locales-zh-MO.ts +17 -4
- package/src/client/locales-zh-TW.ts +17 -4
- package/src/client/locales.ts +41 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/sidebar.module.css +68 -0
- package/src/client/state.ts +56 -10
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/fs-operations.ts +126 -4
- package/src/git.ts +56 -3
- package/src/index.ts +82 -7
- package/src/open-external.ts +3 -4
- package/src/prefs-shared.ts +7 -5
- package/src/pty-manager.ts +176 -5
- package/src/sidechat-routes.ts +13 -1
- package/src/tools.ts +24 -6
- package/src/wire.ts +3 -0
package/src/client/DiffView.tsx
CHANGED
|
@@ -9,9 +9,12 @@
|
|
|
9
9
|
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
|
|
10
10
|
* cases are unit-tested without a DOM.
|
|
11
11
|
*/
|
|
12
|
-
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
12
|
+
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
13
13
|
import clsx from 'clsx'
|
|
14
14
|
import { t } from './locales.ts'
|
|
15
|
+
import { api, type SessionScope } from './api.ts'
|
|
16
|
+
import type { SidebarDiffRef } from './state.ts'
|
|
17
|
+
import { resolveSidebarPath } from './produced-files.ts'
|
|
15
18
|
import css from './sidebar.module.css'
|
|
16
19
|
|
|
17
20
|
/** One rendered diff line. */
|
|
@@ -157,6 +160,61 @@ function displayPath(path: string): string {
|
|
|
157
160
|
return path
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
/** The old/new line range one hidden gap spans (both sides derive from the
|
|
164
|
+
* surrounding hunk headers and their counted rows). */
|
|
165
|
+
interface DiffFoldRange {
|
|
166
|
+
oldStart: number
|
|
167
|
+
oldEnd: number
|
|
168
|
+
newStart: number
|
|
169
|
+
newEnd: number
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** How many old-side rows a hunk carries (rows without an old number are
|
|
173
|
+
* pure additions and do not advance the old side). */
|
|
174
|
+
function hunkOldEnd(hunk: DiffHunk): number {
|
|
175
|
+
return hunk.oldStart + hunk.lines.filter(line => line.oldNum !== null).length - 1
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** How many new-side rows a hunk carries. */
|
|
179
|
+
function hunkNewEnd(hunk: DiffHunk): number {
|
|
180
|
+
return hunk.newStart + hunk.lines.filter(line => line.newNum !== null).length - 1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Materialize a git gap fold's hidden rows from the two sides' full file
|
|
185
|
+
* contents, by the fold's known line ranges: the old side drives context
|
|
186
|
+
* rows (each mapped onto the new side through the fold's offset — a gap is
|
|
187
|
+
* an unchanged run, so the sides align), and new-side lines the old range
|
|
188
|
+
* never reaches become pure additions. Line numbers clip to the actual
|
|
189
|
+
* content (a no-newline file's ranges can overrun by one); `\r` endings
|
|
190
|
+
* survive verbatim, like git's own context lines.
|
|
191
|
+
*/
|
|
192
|
+
export function foldRowsFromContents(fold: DiffFoldRange, oldContent: string, newContent: string): DiffLine[] {
|
|
193
|
+
const oldLines = oldContent.length === 0 ? [] : oldContent.split('\n')
|
|
194
|
+
const newLines = newContent.length === 0 ? [] : newContent.split('\n')
|
|
195
|
+
const offset = fold.newStart - fold.oldStart
|
|
196
|
+
const rows: DiffLine[] = []
|
|
197
|
+
const oldFrom = Math.max(fold.oldStart, 1)
|
|
198
|
+
const oldTo = Math.min(fold.oldEnd, oldLines.length)
|
|
199
|
+
for (let oldLine = oldFrom; oldLine <= oldTo; oldLine += 1) {
|
|
200
|
+
const text = oldLines[oldLine - 1] ?? ''
|
|
201
|
+
const newLine = oldLine + offset
|
|
202
|
+
rows.push(
|
|
203
|
+
newLine >= fold.newStart && newLine <= fold.newEnd && newLine <= newLines.length
|
|
204
|
+
? { kind: 'ctx', text, oldNum: oldLine, newNum: newLine }
|
|
205
|
+
: { kind: 'ctx', text, oldNum: oldLine, newNum: null },
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
// New-side lines beyond what the old range reached (a pure-addition gap):
|
|
209
|
+
// rows carrying only the new-side number, exactly like added lines.
|
|
210
|
+
const newFrom = Math.max(Math.max(fold.newStart, 1), oldTo + offset + 1)
|
|
211
|
+
const newTo = Math.min(fold.newEnd, newLines.length)
|
|
212
|
+
for (let newLine = newFrom; newLine <= newTo; newLine += 1) {
|
|
213
|
+
rows.push({ kind: 'add', text: newLines[newLine - 1] ?? '', oldNum: null, newNum: newLine })
|
|
214
|
+
}
|
|
215
|
+
return rows
|
|
216
|
+
}
|
|
217
|
+
|
|
160
218
|
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
|
|
161
219
|
function fileTag(file: DiffFile): string | null {
|
|
162
220
|
if (file.binary) return t('diffBinary')
|
|
@@ -196,9 +254,20 @@ export interface DiffViewProps {
|
|
|
196
254
|
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
|
197
255
|
untrackedPath?: string
|
|
198
256
|
untrackedContent?: string
|
|
257
|
+
/**
|
|
258
|
+
* When present (a worktree/commit diff ref plus its scope), hunk gaps
|
|
259
|
+
* render an expandable fold: clicking resolves both sides' full contents
|
|
260
|
+
* (`git.fold-contents`) and materializes the hidden context rows. Absent
|
|
261
|
+
* (or an untracked full-addition render) — no fold rows at all.
|
|
262
|
+
*/
|
|
263
|
+
foldSource?: { scope: SessionScope; ref: SidebarDiffRef; cwd: string | undefined }
|
|
199
264
|
}
|
|
200
265
|
|
|
201
|
-
|
|
266
|
+
/** Resolution state of one fold: rows once expanded, 'failed' degrades to a
|
|
267
|
+
* static marker; loading folds are not clickable again. */
|
|
268
|
+
type FoldState = { status: 'loading' } | { status: 'ready'; rows: DiffLine[] } | { status: 'failed' }
|
|
269
|
+
|
|
270
|
+
export function DiffView({ diff, untrackedPath, untrackedContent, foldSource }: DiffViewProps) {
|
|
202
271
|
const parsed = useMemo<ParsedDiff>(() => {
|
|
203
272
|
if (untrackedPath !== undefined) {
|
|
204
273
|
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
|
@@ -207,16 +276,80 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
|
|
|
207
276
|
}, [diff, untrackedPath, untrackedContent])
|
|
208
277
|
const [expanded, setExpanded] = useState(false)
|
|
209
278
|
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
|
|
279
|
+
// Per-fold expansion state (key `f<file>fold<hunk>`): loading → ready
|
|
280
|
+
// (materialized rows) or failed (a side is unavailable — untracked /
|
|
281
|
+
// deleted / binary). In-flight promises dedupe double clicks and remounts.
|
|
282
|
+
const [foldRows, setFoldRows] = useState<Map<string, FoldState>>(new Map())
|
|
283
|
+
const foldInflight = useRef(new Map<string, Promise<void>>())
|
|
210
284
|
|
|
211
285
|
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
setFoldRows(new Map())
|
|
288
|
+
foldInflight.current.clear()
|
|
289
|
+
}, [parsed])
|
|
290
|
+
|
|
291
|
+
/** Fetch both sides' contents and materialize one fold's hidden rows. */
|
|
292
|
+
const resolveFold = (key: string, file: DiffFile, fold: DiffFoldRange): void => {
|
|
293
|
+
if (foldSource === undefined || foldInflight.current.has(key)) return
|
|
294
|
+
setFoldRows(current => new Map(current).set(key, { status: 'loading' }))
|
|
295
|
+
const task = (async (): Promise<void> => {
|
|
296
|
+
try {
|
|
297
|
+
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
|
298
|
+
const contents = await api.gitFoldContents(foldSource.scope, {
|
|
299
|
+
path,
|
|
300
|
+
...(foldSource.ref.kind === 'commit' ? { hash: foldSource.ref.hashFull } : { staged: foldSource.ref.staged === true }),
|
|
301
|
+
}, foldSource.ref.kind === 'worktree' ? foldSource.ref.worktree : undefined)
|
|
302
|
+
if (contents.old === null || contents.new === null) {
|
|
303
|
+
setFoldRows(current => new Map(current).set(key, { status: 'failed' }))
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
const rows = foldRowsFromContents(fold, contents.old, contents.new)
|
|
307
|
+
setFoldRows(current => new Map(current).set(key, { status: 'ready', rows }))
|
|
308
|
+
} catch {
|
|
309
|
+
setFoldRows(current => new Map(current).set(key, { status: 'failed' }))
|
|
310
|
+
} finally {
|
|
311
|
+
foldInflight.current.delete(key)
|
|
312
|
+
}
|
|
313
|
+
})()
|
|
314
|
+
foldInflight.current.set(key, task)
|
|
315
|
+
}
|
|
212
316
|
|
|
213
317
|
// Flatten into display rows so the cap can slice a single list.
|
|
214
318
|
const rows = useMemo(() => {
|
|
215
|
-
const out: Array<
|
|
319
|
+
const out: Array<
|
|
320
|
+
{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }
|
|
321
|
+
| { key: string; file: DiffFile; fileIndex: number; type: 'fold'; fold: DiffFoldRange; count: number; state: FoldState | undefined }
|
|
322
|
+
> = []
|
|
216
323
|
parsed.files.forEach((file, fileIndex) => {
|
|
217
324
|
out.push({ key: `f${fileIndex}`, file, fileIndex, type: 'path' })
|
|
218
325
|
if (file.binary || !expandedFiles.has(fileIndex)) return
|
|
326
|
+
let prevOldEnd = 0
|
|
327
|
+
let prevNewEnd = 0
|
|
219
328
|
file.hunks.forEach((hunk, hunkIndex) => {
|
|
329
|
+
// The gap git never emitted: rows are unknown until resolved, but
|
|
330
|
+
// the ranges derive from the surrounding hunk headers/counts (the
|
|
331
|
+
// first hunk's gap is the leading context git trimmed).
|
|
332
|
+
const oldGap = hunk.oldStart - prevOldEnd - 1
|
|
333
|
+
const newGap = hunk.newStart - prevNewEnd - 1
|
|
334
|
+
if (foldSource !== undefined && (oldGap > 0 || newGap > 0)) {
|
|
335
|
+
const key = `f${fileIndex}fold${hunkIndex}`
|
|
336
|
+
out.push({
|
|
337
|
+
key,
|
|
338
|
+
file,
|
|
339
|
+
fileIndex,
|
|
340
|
+
type: 'fold',
|
|
341
|
+
fold: {
|
|
342
|
+
oldStart: prevOldEnd + 1,
|
|
343
|
+
oldEnd: Math.max(hunk.oldStart - 1, prevOldEnd),
|
|
344
|
+
newStart: prevNewEnd + 1,
|
|
345
|
+
newEnd: Math.max(hunk.newStart - 1, prevNewEnd),
|
|
346
|
+
},
|
|
347
|
+
count: Math.max(oldGap, newGap, 0),
|
|
348
|
+
state: foldRows.get(key),
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
prevOldEnd = hunkOldEnd(hunk)
|
|
352
|
+
prevNewEnd = hunkNewEnd(hunk)
|
|
220
353
|
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, fileIndex, type: 'hunk', hunk })
|
|
221
354
|
hunk.lines.forEach((line, lineIndex) => {
|
|
222
355
|
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, fileIndex, type: 'line', hunk, line })
|
|
@@ -224,7 +357,7 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
|
|
|
224
357
|
})
|
|
225
358
|
})
|
|
226
359
|
return out
|
|
227
|
-
}, [parsed, expandedFiles])
|
|
360
|
+
}, [parsed, expandedFiles, foldRows, foldSource])
|
|
228
361
|
|
|
229
362
|
const hidden = rows.length - MAX_DIFF_ROWS
|
|
230
363
|
const capped = hidden > 0 && !expanded
|
|
@@ -235,6 +368,23 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
|
|
|
235
368
|
|
|
236
369
|
if (rows.length === 0) return null
|
|
237
370
|
|
|
371
|
+
const renderLine = (line: DiffLine, key: string): ReactNode => {
|
|
372
|
+
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
|
|
373
|
+
return (
|
|
374
|
+
<div key={key} className={clsx(css.gitDiffLine, lineClass)}>
|
|
375
|
+
{line.kind === 'meta'
|
|
376
|
+
? <span className={css.gitDiffMetaText}>{line.text}</span>
|
|
377
|
+
: (
|
|
378
|
+
<>
|
|
379
|
+
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
|
|
380
|
+
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
|
|
381
|
+
<span className={css.gitDiffCode}>{line.text}</span>
|
|
382
|
+
</>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
238
388
|
const renderRow = (row: (typeof rows)[number]): ReactNode => {
|
|
239
389
|
if (row.type === 'path') {
|
|
240
390
|
const tag = fileTag(row.file)
|
|
@@ -274,21 +424,26 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
|
|
|
274
424
|
</div>
|
|
275
425
|
)
|
|
276
426
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
427
|
+
if (row.type === 'fold') {
|
|
428
|
+
// Resolved: the chip is replaced by the materialized context rows.
|
|
429
|
+
if (row.state?.status === 'ready') {
|
|
430
|
+
return <Fragment key={row.key}>{row.state.rows.map((line, index) => renderLine(line, `${row.key}r${index}`))}</Fragment>
|
|
431
|
+
}
|
|
432
|
+
const loading = row.state?.status === 'loading'
|
|
433
|
+
const failed = row.state?.status === 'failed'
|
|
434
|
+
return (
|
|
435
|
+
<button
|
|
436
|
+
key={row.key}
|
|
437
|
+
type="button"
|
|
438
|
+
className={clsx(css.gitFoldRow, failed && css.gitFoldRowFailed)}
|
|
439
|
+
disabled={loading || failed}
|
|
440
|
+
onClick={() => { resolveFold(row.key, row.file, row.fold) }}
|
|
441
|
+
>
|
|
442
|
+
{failed ? t('gitFoldFailed') : loading ? t('gitFoldLoading') : t('gitFoldExpand', { count: row.count })}
|
|
443
|
+
</button>
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
return renderLine(row.line!, row.key)
|
|
292
447
|
}
|
|
293
448
|
|
|
294
449
|
return (
|
|
@@ -107,9 +107,12 @@ export function EditorHost(props: {
|
|
|
107
107
|
expanded: string[]
|
|
108
108
|
revealed: string[]
|
|
109
109
|
onToggleDir: (path: string) => void
|
|
110
|
-
onReferenceFile: (path: string) => void
|
|
110
|
+
onReferenceFile: (path: string, isDir: boolean) => void
|
|
111
|
+
/** Tree-row mutations (passed through to the file tree; absent → hidden). */
|
|
112
|
+
onPathRenamed?: (oldPath: string, newPath: string) => void
|
|
113
|
+
onPathRemoved?: (path: string) => void
|
|
111
114
|
}) {
|
|
112
|
-
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
|
|
115
|
+
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile, onPathRenamed, onPathRemoved } = props
|
|
113
116
|
const path = tab.path ?? ''
|
|
114
117
|
// A folder window: the model's `sidebar_open` (or any caller) opens a
|
|
115
118
|
// directory as an editor tab carrying `meta.dir: true` with the directory
|
|
@@ -339,6 +342,8 @@ export function EditorHost(props: {
|
|
|
339
342
|
onOpenWith={openWith}
|
|
340
343
|
onToggleOpenWithPin={toggleOpenWithPin}
|
|
341
344
|
onReferenceFile={onReferenceFile}
|
|
345
|
+
onPathRenamed={onPathRenamed}
|
|
346
|
+
onPathRemoved={onPathRemoved}
|
|
342
347
|
/>
|
|
343
348
|
</div>
|
|
344
349
|
)
|
|
@@ -432,6 +437,8 @@ export function EditorHost(props: {
|
|
|
432
437
|
onOpenWith={openWith}
|
|
433
438
|
onToggleOpenWithPin={toggleOpenWithPin}
|
|
434
439
|
onReferenceFile={onReferenceFile}
|
|
440
|
+
onPathRenamed={onPathRenamed}
|
|
441
|
+
onPathRemoved={onPathRemoved}
|
|
435
442
|
/>
|
|
436
443
|
</div>
|
|
437
444
|
)}
|
package/src/client/FileTree.tsx
CHANGED
|
@@ -25,7 +25,7 @@ import { createPortal } from 'react-dom'
|
|
|
25
25
|
import clsx from 'clsx'
|
|
26
26
|
import {
|
|
27
27
|
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
|
28
|
-
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
|
|
28
|
+
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, Modal, Button, writeClipboard,
|
|
29
29
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
30
30
|
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
|
31
31
|
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
|
@@ -127,22 +127,32 @@ export function FileTree(props: {
|
|
|
127
127
|
onOpenWith?: (targetId: string, path: string) => void
|
|
128
128
|
/** Toggle one target's pinned state (the submenu row's pushpin). */
|
|
129
129
|
onToggleOpenWithPin?: (targetId: string) => void
|
|
130
|
-
/** Insert `@<relative path>` into the composer draft. */
|
|
131
|
-
onReferenceFile: (path: string) => void
|
|
130
|
+
/** Insert `@<relative path>` into the composer draft (file vs directory). */
|
|
131
|
+
onReferenceFile: (path: string, isDir: boolean) => void
|
|
132
132
|
/** Bump to wipe the level cache and reload the visible set. */
|
|
133
133
|
refreshTick: number
|
|
134
134
|
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
|
|
135
135
|
onUploadRequest: (dir: string, items: UploadItem[]) => void
|
|
136
136
|
/** True while an upload is in flight (drops are ignored). */
|
|
137
137
|
busy: boolean
|
|
138
|
+
/** A tree row was renamed (retarget open tabs; absent → no rename entry). */
|
|
139
|
+
onPathRenamed?: (oldPath: string, newPath: string) => void
|
|
140
|
+
/** A tree row was removed (close affected tabs; absent → no delete entry). */
|
|
141
|
+
onPathRemoved?: (path: string) => void
|
|
138
142
|
}) {
|
|
139
|
-
const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
|
143
|
+
const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy, onPathRenamed, onPathRemoved } = props
|
|
140
144
|
const [data, setData] = useState<Record<string, LevelData>>({})
|
|
141
145
|
const dataRef = useRef(data)
|
|
142
146
|
/** The row whose path was just copied ("copied" label replaces its button). */
|
|
143
147
|
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
|
144
148
|
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
|
145
149
|
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
|
150
|
+
/** The row being renamed inline (pre-filled base name; Enter commits). */
|
|
151
|
+
const [renaming, setRenaming] = useState<{ path: string; name: string } | null>(null)
|
|
152
|
+
/** The row pending delete confirmation (the Modal owns the final call). */
|
|
153
|
+
const [deleting, setDeleting] = useState<{ path: string; isDir: boolean } | null>(null)
|
|
154
|
+
/** The last mutation failure (dismissable strip above the tree). */
|
|
155
|
+
const [mutationError, setMutationError] = useState<string | null>(null)
|
|
146
156
|
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
|
|
147
157
|
const [dropOver, setDropOver] = useState(false)
|
|
148
158
|
/** The directory a drag is hovering right now (null = body, drop to root). */
|
|
@@ -297,6 +307,77 @@ export function FileTree(props: {
|
|
|
297
307
|
})
|
|
298
308
|
}, [])
|
|
299
309
|
|
|
310
|
+
/** Re-fetch one directory level (a mutation changed it on disk). */
|
|
311
|
+
const reloadDir = useCallback((dir: string) => {
|
|
312
|
+
dataRef.current = { ...dataRef.current, [dir]: {} }
|
|
313
|
+
setData(dataRef.current)
|
|
314
|
+
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
|
|
315
|
+
storeLevel(dir, { entries: listing.entries })
|
|
316
|
+
}).catch((error: unknown) => {
|
|
317
|
+
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
|
|
318
|
+
})
|
|
319
|
+
}, [sessionId, cwd, storeLevel])
|
|
320
|
+
|
|
321
|
+
/** Commit the inline rename: single-segment name; the row reloads from
|
|
322
|
+
* its parent and open tabs retarget through the caller. */
|
|
323
|
+
const commitRename = (target: { path: string; name: string }): void => {
|
|
324
|
+
const name = target.name.trim()
|
|
325
|
+
if (name === '' || name.includes('/') || name.includes('\\')) {
|
|
326
|
+
setMutationError(t('renameInvalid'))
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
api.fsRename({ sessionId, cwd }, target.path, name).then(({ path }) => {
|
|
330
|
+
setRenaming(null)
|
|
331
|
+
reloadDir(parentOf(target.path) ?? path)
|
|
332
|
+
onPathRenamed?.(target.path, path)
|
|
333
|
+
}).catch((error: unknown) => {
|
|
334
|
+
setMutationError(error instanceof Error ? error.message : String(error))
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Commit the confirmed delete: the parent reloads and the caller closes
|
|
339
|
+
* every open tab at or under the removed path. */
|
|
340
|
+
const commitDelete = (target: { path: string }): void => {
|
|
341
|
+
api.fsRemove({ sessionId, cwd }, target.path).then(() => {
|
|
342
|
+
setDeleting(null)
|
|
343
|
+
reloadDir(parentOf(target.path) ?? target.path)
|
|
344
|
+
onPathRemoved?.(target.path)
|
|
345
|
+
}).catch((error: unknown) => {
|
|
346
|
+
setMutationError(error instanceof Error ? error.message : String(error))
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The inline rename input (auto-focused, pre-selected; Enter/blur commits,
|
|
351
|
+
* Esc cancels, an IME composition never triggers the key handlers). */
|
|
352
|
+
const renameCancelled = useRef(false)
|
|
353
|
+
const renderRenameInput = (path: string): ReactNode => (
|
|
354
|
+
<input
|
|
355
|
+
autoFocus
|
|
356
|
+
className={css.explorerRenameInput}
|
|
357
|
+
defaultValue={renaming?.name ?? ''}
|
|
358
|
+
onClick={(event) => { event.stopPropagation() }}
|
|
359
|
+
onKeyDown={(event) => {
|
|
360
|
+
event.stopPropagation()
|
|
361
|
+
if (event.nativeEvent.isComposing) return
|
|
362
|
+
if (event.key === 'Enter') {
|
|
363
|
+
event.preventDefault()
|
|
364
|
+
if (renaming !== null) commitRename(renaming)
|
|
365
|
+
} else if (event.key === 'Escape') {
|
|
366
|
+
event.preventDefault()
|
|
367
|
+
renameCancelled.current = true
|
|
368
|
+
setRenaming(null)
|
|
369
|
+
}
|
|
370
|
+
}}
|
|
371
|
+
onBlur={() => {
|
|
372
|
+
if (renameCancelled.current) {
|
|
373
|
+
renameCancelled.current = false
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
if (renaming !== null) commitRename(renaming)
|
|
377
|
+
}}
|
|
378
|
+
/>
|
|
379
|
+
)
|
|
380
|
+
|
|
300
381
|
/** The row's trailing actions: the @-reference button, or the copied label. */
|
|
301
382
|
const rowActions = (entry: FsEntry): ReactNode => {
|
|
302
383
|
if (copiedPath === entry.path) {
|
|
@@ -310,7 +391,7 @@ export function FileTree(props: {
|
|
|
310
391
|
title={t('referenceFile')}
|
|
311
392
|
onClick={(event) => {
|
|
312
393
|
event.stopPropagation()
|
|
313
|
-
onReferenceFile(entry.path)
|
|
394
|
+
onReferenceFile(entry.path, entry.isDir)
|
|
314
395
|
}}
|
|
315
396
|
>
|
|
316
397
|
{t('referenceFile')}
|
|
@@ -459,7 +540,9 @@ export function FileTree(props: {
|
|
|
459
540
|
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
|
460
541
|
>
|
|
461
542
|
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
|
|
462
|
-
|
|
543
|
+
{renaming?.path === entry.path
|
|
544
|
+
? renderRenameInput(entry.path)
|
|
545
|
+
: <span className={css.explorerName}>{entry.name}</span>}
|
|
463
546
|
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
|
464
547
|
{rowActions(entry)}
|
|
465
548
|
</div>
|
|
@@ -492,7 +575,9 @@ export function FileTree(props: {
|
|
|
492
575
|
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
|
493
576
|
>
|
|
494
577
|
<VscFile size={14} />
|
|
495
|
-
|
|
578
|
+
{renaming?.path === entry.path
|
|
579
|
+
? renderRenameInput(entry.path)
|
|
580
|
+
: <span className={css.explorerName}>{entry.name}</span>}
|
|
496
581
|
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
|
497
582
|
{rowActions(entry)}
|
|
498
583
|
</div>
|
|
@@ -532,7 +617,7 @@ export function FileTree(props: {
|
|
|
532
617
|
title={t('referenceFile')}
|
|
533
618
|
onClick={(event) => {
|
|
534
619
|
event.stopPropagation()
|
|
535
|
-
onReferenceFile(root)
|
|
620
|
+
onReferenceFile(root, true)
|
|
536
621
|
}}
|
|
537
622
|
>
|
|
538
623
|
{t('referenceFile')}
|
|
@@ -606,6 +691,20 @@ export function FileTree(props: {
|
|
|
606
691
|
event.target.value = ''
|
|
607
692
|
}}
|
|
608
693
|
/>
|
|
694
|
+
{mutationError !== null && (
|
|
695
|
+
<div className={clsx(css.explorerRow, css.explorerError)}>
|
|
696
|
+
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>{mutationError}</span>
|
|
697
|
+
<button
|
|
698
|
+
type="button"
|
|
699
|
+
className={css.explorerRef}
|
|
700
|
+
aria-label={t('dismiss')}
|
|
701
|
+
title={t('dismiss')}
|
|
702
|
+
onClick={() => { setMutationError(null) }}
|
|
703
|
+
>
|
|
704
|
+
{t('dismiss')}
|
|
705
|
+
</button>
|
|
706
|
+
</div>
|
|
707
|
+
)}
|
|
609
708
|
<Menu
|
|
610
709
|
open={rowMenu !== null}
|
|
611
710
|
onClose={() => { setRowMenu(null) }}
|
|
@@ -628,6 +727,18 @@ export function FileTree(props: {
|
|
|
628
727
|
: []),
|
|
629
728
|
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
|
|
630
729
|
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
|
|
730
|
+
// Tree mutations (the workspace root row never offers them; the
|
|
731
|
+
// server refuses too). Absent callbacks keep the entries hidden.
|
|
732
|
+
...((onPathRenamed !== undefined && rowMenu !== null && rowMenu.path !== root)
|
|
733
|
+
|| (onPathRemoved !== undefined && rowMenu !== null && rowMenu.path !== root)
|
|
734
|
+
? [{ id: 'mutation-sep', type: 'separator' } as MenuEntry]
|
|
735
|
+
: []),
|
|
736
|
+
...(onPathRenamed !== undefined && rowMenu !== null && rowMenu.path !== root
|
|
737
|
+
? [{ id: 'rename-row', label: t('rename') }]
|
|
738
|
+
: []),
|
|
739
|
+
...(onPathRemoved !== undefined && rowMenu !== null && rowMenu.path !== root
|
|
740
|
+
? [{ id: 'delete-row', label: t('delete') }]
|
|
741
|
+
: []),
|
|
631
742
|
]}
|
|
632
743
|
onSelect={(id) => {
|
|
633
744
|
const target = rowMenu
|
|
@@ -654,6 +765,15 @@ export function FileTree(props: {
|
|
|
654
765
|
fileInputRef.current?.click()
|
|
655
766
|
return
|
|
656
767
|
}
|
|
768
|
+
if (id === 'rename-row') {
|
|
769
|
+
setMutationError(null)
|
|
770
|
+
setRenaming({ path: target.path, name: baseName(target.path) })
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
if (id === 'delete-row') {
|
|
774
|
+
setDeleting({ path: target.path, isDir: target.isDir })
|
|
775
|
+
return
|
|
776
|
+
}
|
|
657
777
|
copyPath(
|
|
658
778
|
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
|
659
779
|
target.path,
|
|
@@ -664,6 +784,29 @@ export function FileTree(props: {
|
|
|
664
784
|
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
|
665
785
|
anchor={<span />}
|
|
666
786
|
/>
|
|
787
|
+
<Modal
|
|
788
|
+
open={deleting !== null}
|
|
789
|
+
onClose={() => { setDeleting(null) }}
|
|
790
|
+
title={deleting === null ? '' : t('deleteTitle', { name: baseName(deleting.path) })}
|
|
791
|
+
closeLabel={t('cancel')}
|
|
792
|
+
footer={(
|
|
793
|
+
<>
|
|
794
|
+
<Button variant="outline" onClick={() => { setDeleting(null) }}>{t('cancel')}</Button>
|
|
795
|
+
<Button
|
|
796
|
+
variant="primary"
|
|
797
|
+
onClick={() => {
|
|
798
|
+
const target = deleting
|
|
799
|
+
if (target === null) return
|
|
800
|
+
commitDelete(target)
|
|
801
|
+
}}
|
|
802
|
+
>
|
|
803
|
+
{t('delete')}
|
|
804
|
+
</Button>
|
|
805
|
+
</>
|
|
806
|
+
)}
|
|
807
|
+
>
|
|
808
|
+
<p className={css.explorerError}>{deleting?.isDir === true ? t('deleteDescDir') : t('deleteDescFile')}</p>
|
|
809
|
+
</Modal>
|
|
667
810
|
</div>
|
|
668
811
|
)
|
|
669
812
|
}
|