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.
Files changed (81) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +485 -228
  3. package/lib/client-registry.js +1238 -7583
  4. package/lib/client-terminal.js +345 -189
  5. package/lib/client.js +1213 -7558
  6. package/lib/index.js +518 -69
  7. package/lib/types/agent-pty.d.ts +62 -4
  8. package/lib/types/bundle-route.d.ts +1 -1
  9. package/lib/types/client/DiffView.d.ts +33 -1
  10. package/lib/types/client/EditorHost.d.ts +4 -1
  11. package/lib/types/client/FileTree.d.ts +6 -2
  12. package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
  13. package/lib/types/client/TreePanel.d.ts +4 -1
  14. package/lib/types/client/api.d.ts +26 -0
  15. package/lib/types/client/chunk-loader.d.ts +1 -1
  16. package/lib/types/client/chunks/locale.d.ts +3 -0
  17. package/lib/types/client/conversation-draft.d.ts +85 -4
  18. package/lib/types/client/locales.d.ts +13 -20
  19. package/lib/types/client/selection-popup.d.ts +58 -0
  20. package/lib/types/client/service.d.ts +1 -1
  21. package/lib/types/client/state.d.ts +15 -0
  22. package/lib/types/client/terminal-font.d.ts +25 -2
  23. package/lib/types/context-types.d.ts +3 -1
  24. package/lib/types/fs-operations.d.ts +42 -0
  25. package/lib/types/git.d.ts +15 -0
  26. package/lib/types/index.d.ts +9 -0
  27. package/lib/types/prefs-shared.d.ts +7 -5
  28. package/lib/types/pty-manager.d.ts +53 -0
  29. package/lib/types/wire.d.ts +6 -2
  30. package/package.json +1 -1
  31. package/src/agent-pty.ts +221 -33
  32. package/src/bundle-route.ts +1 -1
  33. package/src/client/DiffTab.tsx +10 -1
  34. package/src/client/DiffView.tsx +174 -19
  35. package/src/client/EditorHost.tsx +9 -2
  36. package/src/client/FileTree.tsx +151 -8
  37. package/src/client/Sidebar.tsx +95 -25
  38. package/src/client/TerminalView.tsx +41 -0
  39. package/src/client/TerminalWaitBanner.tsx +32 -0
  40. package/src/client/TextEditor.tsx +27 -45
  41. package/src/client/TreePanel.tsx +22 -2
  42. package/src/client/api.ts +20 -0
  43. package/src/client/chunk-loader.ts +1 -1
  44. package/src/client/chunks/locale.tsx +60 -0
  45. package/src/client/conversation-draft.ts +233 -7
  46. package/src/client/index.tsx +19 -8
  47. package/src/client/locales-ar.ts +17 -4
  48. package/src/client/locales-de.ts +17 -4
  49. package/src/client/locales-fr.ts +17 -4
  50. package/src/client/locales-hi.ts +17 -4
  51. package/src/client/locales-id.ts +17 -4
  52. package/src/client/locales-it.ts +17 -4
  53. package/src/client/locales-ja.ts +17 -4
  54. package/src/client/locales-ko.ts +17 -4
  55. package/src/client/locales-nl.ts +17 -4
  56. package/src/client/locales-pl.ts +17 -4
  57. package/src/client/locales-pt.ts +17 -4
  58. package/src/client/locales-ru.ts +17 -4
  59. package/src/client/locales-sv.ts +17 -4
  60. package/src/client/locales-th.ts +17 -4
  61. package/src/client/locales-tr.ts +17 -4
  62. package/src/client/locales-vi.ts +17 -4
  63. package/src/client/locales-zh-HK.ts +17 -4
  64. package/src/client/locales-zh-MO.ts +17 -4
  65. package/src/client/locales-zh-TW.ts +17 -4
  66. package/src/client/locales.ts +41 -51
  67. package/src/client/selection-popup.ts +155 -0
  68. package/src/client/service.ts +1 -1
  69. package/src/client/sidebar.module.css +68 -0
  70. package/src/client/state.ts +56 -10
  71. package/src/client/terminal-font.ts +34 -3
  72. package/src/context-types.ts +3 -2
  73. package/src/fs-operations.ts +126 -4
  74. package/src/git.ts +56 -3
  75. package/src/index.ts +82 -7
  76. package/src/open-external.ts +3 -4
  77. package/src/prefs-shared.ts +7 -5
  78. package/src/pty-manager.ts +176 -5
  79. package/src/sidechat-routes.ts +13 -1
  80. package/src/tools.ts +24 -6
  81. package/src/wire.ts +3 -0
@@ -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
- export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
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<{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
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
- const line = row.line!
278
- const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
279
- return (
280
- <div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
281
- {line.kind === 'meta'
282
- ? <span className={css.gitDiffMetaText}>{line.text}</span>
283
- : (
284
- <>
285
- <span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
286
- <span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
287
- <span className={css.gitDiffCode}>{line.text}</span>
288
- </>
289
- )}
290
- </div>
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
  )}
@@ -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
- <span className={css.explorerName}>{entry.name}</span>
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
- <span className={css.explorerName}>{entry.name}</span>
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
  }