dsh-coding-sidebar 1.0.8 → 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/lib/client-editor.js +223 -175
- package/lib/client-registry.js +763 -344
- package/lib/client-terminal.js +283 -179
- package/lib/client.js +760 -341
- package/lib/index.js +151 -2
- package/lib/types/client/DiffView.d.ts +33 -1
- package/lib/types/client/EditorHost.d.ts +3 -0
- package/lib/types/client/FileTree.d.ts +4 -0
- package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
- package/lib/types/client/TreePanel.d.ts +3 -0
- package/lib/types/client/api.d.ts +26 -0
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/state.d.ts +15 -0
- package/lib/types/fs-operations.d.ts +42 -0
- package/lib/types/git.d.ts +15 -0
- package/package.json +1 -1
- package/src/client/DiffTab.tsx +10 -1
- package/src/client/DiffView.tsx +174 -19
- package/src/client/EditorHost.tsx +8 -1
- package/src/client/FileTree.tsx +147 -4
- package/src/client/Sidebar.tsx +54 -3
- package/src/client/TerminalView.tsx +28 -0
- package/src/client/TerminalWaitBanner.tsx +32 -0
- package/src/client/TreePanel.tsx +6 -1
- package/src/client/api.ts +20 -0
- package/src/client/locales-ar.ts +12 -0
- package/src/client/locales-de.ts +12 -0
- package/src/client/locales-fr.ts +12 -0
- package/src/client/locales-hi.ts +12 -0
- package/src/client/locales-id.ts +12 -0
- package/src/client/locales-it.ts +12 -0
- package/src/client/locales-ja.ts +12 -0
- package/src/client/locales-ko.ts +12 -0
- package/src/client/locales-nl.ts +12 -0
- package/src/client/locales-pl.ts +12 -0
- package/src/client/locales-pt.ts +12 -0
- package/src/client/locales-ru.ts +12 -0
- package/src/client/locales-sv.ts +12 -0
- package/src/client/locales-th.ts +12 -0
- package/src/client/locales-tr.ts +12 -0
- package/src/client/locales-vi.ts +12 -0
- package/src/client/locales-zh-HK.ts +12 -0
- package/src/client/locales-zh-MO.ts +12 -0
- package/src/client/locales-zh-TW.ts +12 -0
- package/src/client/locales.ts +24 -0
- package/src/client/sidebar.module.css +68 -0
- package/src/client/state.ts +42 -3
- package/src/fs-operations.ts +126 -4
- package/src/git.ts +39 -2
- package/src/index.ts +43 -1
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 (
|
|
@@ -108,8 +108,11 @@ export function EditorHost(props: {
|
|
|
108
108
|
revealed: string[]
|
|
109
109
|
onToggleDir: (path: string) => void
|
|
110
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'
|
|
@@ -135,14 +135,24 @@ export function FileTree(props: {
|
|
|
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) {
|
|
@@ -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>
|
|
@@ -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
|
}
|
package/src/client/Sidebar.tsx
CHANGED
|
@@ -28,11 +28,13 @@ import type { Context, SidebarSessionList } from '../context-types.ts'
|
|
|
28
28
|
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
|
29
29
|
import {
|
|
30
30
|
PANEL_MIN, activateTab, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
|
31
|
-
isAgentTabId, leafWithTab,
|
|
31
|
+
isAgentTabId, leafWithTab, allLeaves,
|
|
32
32
|
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals,
|
|
33
33
|
resizeFloat, resizeSplitIn, setTabPin, setWidth, toggleExpanded, togglePanel,
|
|
34
34
|
type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
|
|
35
35
|
} from './state.ts'
|
|
36
|
+
import { baseName } from './FileTree.tsx'
|
|
37
|
+
import { isWithinWorkspace } from './paths.ts'
|
|
36
38
|
import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, isPinnedVirtualTab, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts'
|
|
37
39
|
import { IconPinOutline16 } from './icons.tsx'
|
|
38
40
|
import { IconPanelRightOutline16 } from './icons.tsx'
|
|
@@ -126,11 +128,14 @@ interface TabContentProps extends TabContentMemoKey {
|
|
|
126
128
|
onSubagentJump: (childSessionId: string) => void
|
|
127
129
|
/** Open a diff tab from the git panel (placement handled by the store). */
|
|
128
130
|
onOpenDiff: (tab: SidebarTab) => void
|
|
131
|
+
/** Tree-row mutations (threaded to the file tree; see Sidebar's handlers). */
|
|
132
|
+
onPathRenamed?: (oldPath: string, newPath: string) => void
|
|
133
|
+
onPathRemoved?: (path: string) => void
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
/** Render the content of one tab (dispatched by type). */
|
|
132
137
|
const TabContent = memo(function TabContent(props: TabContentProps) {
|
|
133
|
-
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
|
138
|
+
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff, onPathRenamed, onPathRemoved } = props
|
|
134
139
|
const scope = { sessionId, cwd }
|
|
135
140
|
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
|
136
141
|
if (descriptor === undefined) {
|
|
@@ -394,7 +399,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
394
399
|
socket.onmessage = (event) => {
|
|
395
400
|
if (typeof event.data !== 'string') return
|
|
396
401
|
try {
|
|
397
|
-
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
|
402
|
+
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }>
|
|
398
403
|
if (!Array.isArray(list)) return
|
|
399
404
|
store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
|
|
400
405
|
? s
|
|
@@ -1160,6 +1165,41 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
1160
1165
|
}
|
|
1161
1166
|
}, [ctx, sessionId, cwd])
|
|
1162
1167
|
|
|
1168
|
+
/** Tree-row rename reconciliation: retarget every open tab whose path was
|
|
1169
|
+
* the renamed file (the editor content survives and later saves land on
|
|
1170
|
+
* the new path; the title follows the new base name). */
|
|
1171
|
+
const onPathRenamed = useCallback((oldPath: string, newPath: string): void => {
|
|
1172
|
+
const service = ctx.get('betterSidebar')
|
|
1173
|
+
if (service === undefined) return
|
|
1174
|
+
const snapshot = store.getSnapshot().state
|
|
1175
|
+
if (snapshot === undefined) return
|
|
1176
|
+
for (const leaf of allLeaves(snapshot.splits)) {
|
|
1177
|
+
for (const tab of leaf.tabs) {
|
|
1178
|
+
if (tab.path === oldPath) service.updateTab(tab.id, { path: newPath, title: baseName(newPath) })
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
for (const float of snapshot.floats) {
|
|
1182
|
+
if (float.tab.path === oldPath) service.updateTab(float.tab.id, { path: newPath, title: baseName(newPath) })
|
|
1183
|
+
}
|
|
1184
|
+
}, [ctx, store])
|
|
1185
|
+
|
|
1186
|
+
/** Tree-row delete reconciliation: close every open tab at or under the
|
|
1187
|
+
* removed path (a stale tab's next save would fail against a missing
|
|
1188
|
+
* path). Floating tabs are as open as docked ones. */
|
|
1189
|
+
const onPathRemoved = useCallback((target: string): void => {
|
|
1190
|
+
const service = ctx.get('betterSidebar')
|
|
1191
|
+
if (service === undefined) return
|
|
1192
|
+
const snapshot = store.getSnapshot().state
|
|
1193
|
+
if (snapshot === undefined) return
|
|
1194
|
+
const tabs: SidebarTab[] = []
|
|
1195
|
+
for (const leaf of allLeaves(snapshot.splits)) tabs.push(...leaf.tabs)
|
|
1196
|
+
for (const float of snapshot.floats) tabs.push(float.tab)
|
|
1197
|
+
for (const tab of tabs) {
|
|
1198
|
+
const path = tab.path
|
|
1199
|
+
if (path !== undefined && (path === target || isWithinWorkspace(target, path))) service.closeTab(tab.id)
|
|
1200
|
+
}
|
|
1201
|
+
}, [ctx, store])
|
|
1202
|
+
|
|
1163
1203
|
if (state === undefined || sessionId === undefined) {
|
|
1164
1204
|
// Keep the unavailable controls focusable: touch users have no hover, so
|
|
1165
1205
|
// focus is the only way the existing Tooltip can explain what is missing.
|
|
@@ -1205,6 +1245,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
1205
1245
|
* strip must never break because a plugin's badge computation failed.
|
|
1206
1246
|
*/
|
|
1207
1247
|
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
|
|
1248
|
+
// Agent-terminal wait indicator (sidebar-internal, deliberately NOT a
|
|
1249
|
+
// TabDescriptor.badge — that API is type-keyed and shared with external
|
|
1250
|
+
// plugins, and cannot address one tab): the agent-terminals push mirrors
|
|
1251
|
+
// the model's live terminal_wait_for into state.agentWaits; an agent tab
|
|
1252
|
+
// whose uuid is waiting shows the hourglass pill.
|
|
1253
|
+
if (isAgentTabId(tab.id)) {
|
|
1254
|
+
const wait = state.agentWaits?.[agentUuidOf(tab.id)]
|
|
1255
|
+
if (wait !== undefined) return <span className={css.tabBadge}>{'⏳'}</span>
|
|
1256
|
+
}
|
|
1208
1257
|
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
|
1209
1258
|
if (descriptor?.badge === undefined) return null
|
|
1210
1259
|
let value: string | number | null | undefined
|
|
@@ -1250,6 +1299,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
1250
1299
|
revealed={state.revealed ?? []}
|
|
1251
1300
|
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
|
1252
1301
|
onReferenceFile={referenceInChat}
|
|
1302
|
+
onPathRenamed={onPathRenamed}
|
|
1303
|
+
onPathRemoved={onPathRemoved}
|
|
1253
1304
|
ctx={ctx}
|
|
1254
1305
|
store={store}
|
|
1255
1306
|
visible={placement === 'float' ? true : state.panelOpen && active}
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
shouldActivateTerminalLink,
|
|
48
48
|
openTerminalUrl,
|
|
49
49
|
} from './terminal-links.ts'
|
|
50
|
+
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
|
|
50
51
|
import css from './sidebar.module.css'
|
|
51
52
|
|
|
52
53
|
/** How many consecutive unreasoned failures before showing the error banner. */
|
|
@@ -122,6 +123,27 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
|
|
122
123
|
const [fatal, setFatal] = useState<string | null>(null)
|
|
123
124
|
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
|
|
124
125
|
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
|
126
|
+
// Agent terminals only: the model's active terminal_wait_for (mirrored
|
|
127
|
+
// from the host's agent-terminals push into the store) drives the wait
|
|
128
|
+
// banner. Read + subscribe like the font prefs above; the banner vanishes
|
|
129
|
+
// when the host's push drops the waiting field (skip / exit / abort all
|
|
130
|
+
// converge through the same push). getSnapshot() is {sessionId, state?,
|
|
131
|
+
// prefs} — the state may be briefly undefined around session switches.
|
|
132
|
+
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
|
|
133
|
+
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (agentUuid === null) return
|
|
136
|
+
const read = (): void => {
|
|
137
|
+
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
|
|
138
|
+
setWaiting(prev => {
|
|
139
|
+
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
|
|
140
|
+
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
|
|
141
|
+
return nextValue
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
read()
|
|
145
|
+
return store.subscribe(read)
|
|
146
|
+
}, [agentUuid, store])
|
|
125
147
|
const connectRef = useRef<(() => void) | null>(null)
|
|
126
148
|
|
|
127
149
|
useEffect(() => {
|
|
@@ -374,6 +396,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
|
|
374
396
|
|
|
375
397
|
return (
|
|
376
398
|
<div className={css.terminalWrap}>
|
|
399
|
+
{agentUuid !== null && waiting !== undefined && (
|
|
400
|
+
<TerminalWaitBanner
|
|
401
|
+
needle={waiting.needle}
|
|
402
|
+
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
|
|
403
|
+
/>
|
|
404
|
+
)}
|
|
377
405
|
{depsFatal !== null && (
|
|
378
406
|
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
|
|
379
407
|
)}
|