dsh-coding-sidebar 1.0.8 → 1.0.10

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 (58) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +257 -178
  3. package/lib/client-registry.js +959 -345
  4. package/lib/client-terminal.js +307 -172
  5. package/lib/client.js +952 -338
  6. package/lib/index.js +214 -2
  7. package/lib/types/changes-ops.d.ts +31 -0
  8. package/lib/types/client/DiffView.d.ts +33 -1
  9. package/lib/types/client/EditorHost.d.ts +3 -0
  10. package/lib/types/client/FileTree.d.ts +4 -0
  11. package/lib/types/client/SessionLens.d.ts +4 -0
  12. package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
  13. package/lib/types/client/TreePanel.d.ts +3 -0
  14. package/lib/types/client/api.d.ts +36 -0
  15. package/lib/types/client/locales.d.ts +20 -0
  16. package/lib/types/client/redact.d.ts +14 -0
  17. package/lib/types/client/state.d.ts +15 -0
  18. package/lib/types/fs-operations.d.ts +42 -0
  19. package/lib/types/git.d.ts +15 -0
  20. package/package.json +1 -1
  21. package/src/changes-ops.ts +78 -0
  22. package/src/client/DiffTab.tsx +10 -1
  23. package/src/client/DiffView.tsx +174 -19
  24. package/src/client/EditorHost.tsx +8 -1
  25. package/src/client/FileTree.tsx +147 -4
  26. package/src/client/GitView.tsx +37 -0
  27. package/src/client/SessionLens.tsx +95 -0
  28. package/src/client/Sidebar.tsx +54 -3
  29. package/src/client/TerminalView.tsx +28 -0
  30. package/src/client/TerminalWaitBanner.tsx +32 -0
  31. package/src/client/TreePanel.tsx +6 -1
  32. package/src/client/api.ts +24 -0
  33. package/src/client/locales-ar.ts +20 -0
  34. package/src/client/locales-de.ts +20 -0
  35. package/src/client/locales-fr.ts +20 -0
  36. package/src/client/locales-hi.ts +20 -0
  37. package/src/client/locales-id.ts +20 -0
  38. package/src/client/locales-it.ts +20 -0
  39. package/src/client/locales-ja.ts +20 -0
  40. package/src/client/locales-ko.ts +20 -0
  41. package/src/client/locales-nl.ts +20 -0
  42. package/src/client/locales-pl.ts +20 -0
  43. package/src/client/locales-pt.ts +20 -0
  44. package/src/client/locales-ru.ts +20 -0
  45. package/src/client/locales-sv.ts +20 -0
  46. package/src/client/locales-th.ts +20 -0
  47. package/src/client/locales-tr.ts +20 -0
  48. package/src/client/locales-vi.ts +20 -0
  49. package/src/client/locales-zh-HK.ts +20 -0
  50. package/src/client/locales-zh-MO.ts +20 -0
  51. package/src/client/locales-zh-TW.ts +20 -0
  52. package/src/client/locales.ts +40 -0
  53. package/src/client/redact.ts +39 -0
  54. package/src/client/sidebar.module.css +183 -0
  55. package/src/client/state.ts +42 -3
  56. package/src/fs-operations.ts +126 -4
  57. package/src/git.ts +39 -2
  58. package/src/index.ts +56 -1
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Workspace-safe file mutations for the sidebar (the upload route today).
2
+ * Workspace-safe file mutations for the sidebar: the upload route plus the
3
+ * file tree's rename and delete.
3
4
  *
4
5
  * Every write is confined to the real session workspace: the upload
5
6
  * directory is resolved absolute and its target is checked through existing
@@ -9,14 +10,21 @@
9
10
  * to a uniquely named temp sibling
10
11
  * and are renamed into place, so a failed, aborted, or oversized upload never
11
12
  * leaves a partial file at the target path.
13
+ *
14
+ * The tree's rename/delete (below) are link-aware: existence and containment
15
+ * are verified against the fully resolved target (a symlink pointing outside
16
+ * the workspace is refused), but the operation itself addresses the lexical
17
+ * row path — renaming or deleting a symlink row renames/unlinks the LINK,
18
+ * never its target, matching what the tree row visually names (VS Code
19
+ * semantics). Containment is always enforced (no fence toggle on this fork).
12
20
  */
13
21
  import { randomUUID } from 'node:crypto'
14
22
  import { once } from 'node:events'
15
23
  import { createWriteStream } from 'node:fs'
16
- import { mkdir, rename, rm, stat } from 'node:fs/promises'
17
- import { basename, dirname, join } from 'node:path'
24
+ import { access, lstat, mkdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises'
25
+ import { basename, dirname, isAbsolute, join } from 'node:path'
18
26
  import type { SidebarHttpRequest } from './context-types.ts'
19
- import { requireAbsolute } from './fs-tree.ts'
27
+ import { isWithin, requireAbsolute } from './fs-tree.ts'
20
28
  import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
21
29
  import { SidebarError } from './wire.ts'
22
30
 
@@ -93,3 +101,117 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
93
101
  throw error
94
102
  }
95
103
  }
104
+
105
+ /** Resolve a tree-row path against the session workspace (absolute rows pass through). */
106
+ function resolveRowPath(cwd: string, target: string): string {
107
+ return isAbsolute(target) ? target : join(cwd, target)
108
+ }
109
+
110
+ /** Resolve one existing entry for a link-aware mutation: the lexical row path
111
+ * plus its fully resolved real target (containment-checked). Resolution
112
+ * failures become fs-errors, mirroring path-security's semantics. */
113
+ async function resolveEntry(
114
+ cwd: string,
115
+ target: string,
116
+ ): Promise<{ absolute: string; real: string; realCwd: string }> {
117
+ const absolute = requireAbsolute(resolveRowPath(cwd, target))
118
+ let real: string
119
+ let realCwd: string
120
+ try {
121
+ ;[realCwd, real] = await Promise.all([realpath(cwd), realpath(absolute)])
122
+ } catch (error) {
123
+ throw new SidebarError('fs-error', `cannot resolve "${target}": ${error instanceof Error ? error.message : String(error)}`, 400)
124
+ }
125
+ if (!isWithin(realCwd, real)) {
126
+ throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403)
127
+ }
128
+ return { absolute, real, realCwd }
129
+ }
130
+
131
+ /** Whether a path exists (ENOENT → false; other failures propagate). */
132
+ async function pathExists(target: string): Promise<boolean> {
133
+ try {
134
+ await access(target)
135
+ return true
136
+ } catch (error) {
137
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
138
+ throw error
139
+ }
140
+ }
141
+
142
+ /** Inputs of one tree-row rename. */
143
+ export interface WorkspaceRenameInput {
144
+ /** The session workspace root; the renamed entry must stay inside it. */
145
+ cwd: string
146
+ /** Absolute path of the row as the tree displays it (may be a symlink). */
147
+ path: string
148
+ /** The new base name (single segment — rename never moves across directories). */
149
+ name: string
150
+ }
151
+
152
+ /**
153
+ * Rename one tree row within its directory: `path` → `<parent>/<name>`.
154
+ * The new name must be a single path segment (this is rename, not move);
155
+ * an existing destination is refused (POSIX rename would clobber it
156
+ * silently); the workspace root itself is never renamable; a symlink row
157
+ * renames the link, not its target. A no-op rename (same name) succeeds
158
+ * without touching the filesystem.
159
+ *
160
+ * @throws SidebarError with a wire code for shape, containment, existence
161
+ * and root failures.
162
+ */
163
+ export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise<{ path: string }> {
164
+ const { cwd, path, name } = input
165
+ if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
166
+ throw new SidebarError('bad-request', 'name must be a single path segment', 400)
167
+ }
168
+ const { absolute, real, realCwd } = await resolveEntry(cwd, path)
169
+ if (real === realCwd) {
170
+ throw new SidebarError('fs-error', 'cannot rename the workspace root', 400)
171
+ }
172
+ if (basename(absolute) === name) return { path: absolute }
173
+ const destination = join(dirname(absolute), name)
174
+ const safeDestination = await ensureWorkspaceWritePath(cwd, destination)
175
+ if (await pathExists(safeDestination)) {
176
+ throw new SidebarError('fs-error', `"${name}" already exists`, 409)
177
+ }
178
+ try {
179
+ await rename(absolute, safeDestination)
180
+ } catch (error) {
181
+ throw new SidebarError('fs-error', `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400)
182
+ }
183
+ return { path: safeDestination }
184
+ }
185
+
186
+ /** Inputs of one tree-row delete. */
187
+ export interface WorkspaceRemoveInput {
188
+ /** The session workspace root; the removed entry must stay inside it. */
189
+ cwd: string
190
+ /** Absolute path of the row as the tree displays it (may be a symlink). */
191
+ path: string
192
+ }
193
+
194
+ /**
195
+ * Delete one tree row permanently (there is no trash on the host): files are
196
+ * unlinked, directories removed recursively, a symlink row unlinks the LINK
197
+ * only (lstat decides, so a link to a directory does not recurse into its
198
+ * target). The workspace root itself is never removable.
199
+ *
200
+ * @throws SidebarError with a wire code for containment, existence and
201
+ * root failures.
202
+ */
203
+ export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> {
204
+ const { cwd, path } = input
205
+ const { absolute, real, realCwd } = await resolveEntry(cwd, path)
206
+ if (real === realCwd) {
207
+ throw new SidebarError('fs-error', 'cannot remove the workspace root', 400)
208
+ }
209
+ try {
210
+ const info = await lstat(absolute)
211
+ if (info.isDirectory()) await rm(absolute, { recursive: true })
212
+ else await unlink(absolute)
213
+ } catch (error) {
214
+ throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
215
+ }
216
+ return { path: absolute }
217
+ }
package/src/git.ts CHANGED
@@ -9,8 +9,8 @@
9
9
  * Commits use the user's git global identity untouched (never sets
10
10
  * user.name/user.email).
11
11
  */
12
- import { readdir } from 'node:fs/promises'
13
- import { join } from 'node:path'
12
+ import { readdir, readFile } from 'node:fs/promises'
13
+ import { isAbsolute, join } from 'node:path'
14
14
  import { spawn } from 'node:child_process'
15
15
  import { resolve } from 'node:path'
16
16
 
@@ -439,6 +439,43 @@ export async function show(cwd: string, rev: string, path: string, selected?: st
439
439
  }
440
440
  }
441
441
 
442
+ /**
443
+ * Both sides' full file contents for a diff-fold expansion. `path` is
444
+ * repo-relative. The sides resolve per diff kind: a commit reads
445
+ * `<hash>^` vs `<hash>`; a staged change reads HEAD vs the index (`:`);
446
+ * an unstaged change reads HEAD vs the working tree file on disk (a side
447
+ * that does not exist — untracked, deleted, binary-refused — comes back
448
+ * null and the client degrades the fold to a static marker).
449
+ */
450
+ export async function foldContents(
451
+ cwd: string,
452
+ path: string,
453
+ opts: { staged?: boolean; hash?: string } = {},
454
+ selected?: string,
455
+ ): Promise<{ old: string | null; new: string | null }> {
456
+ const root = await repoRoot(cwd, selected)
457
+ if (opts.hash !== undefined) {
458
+ const [old, neu] = await Promise.all([
459
+ show(root, `${opts.hash}^`, path, selected),
460
+ show(root, opts.hash, path, selected),
461
+ ])
462
+ return { old, new: neu }
463
+ }
464
+ if (opts.staged === true) {
465
+ // `git show :<path>` reads the index (stage 0) — the staged side.
466
+ const [old, neu] = await Promise.all([
467
+ show(root, 'HEAD', path, selected),
468
+ show(root, ':', path, selected),
469
+ ])
470
+ return { old, new: neu }
471
+ }
472
+ const [old, neu] = await Promise.all([
473
+ show(root, 'HEAD', path, selected),
474
+ readFile(isAbsolute(path) ? path : join(root, path), 'utf8').catch(() => null),
475
+ ])
476
+ return { old, new: neu }
477
+ }
478
+
442
479
  /** Full patch text of one commit (`git show` with the commit header suppressed).
443
480
  * Merge commits show their diff against the first parent (`-m --first-parent`
444
481
  * is a no-op for regular commits), so a history click always has content. */
package/src/index.ts CHANGED
@@ -30,7 +30,8 @@ import {
30
30
  type SidebarPrefs,
31
31
  } from './config.ts'
32
32
  import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
33
- import { writeWorkspaceUpload } from './fs-operations.ts'
33
+ import { removeWorkspaceEntry, renameWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts'
34
+ import { sessionFileOps } from './changes-ops.ts'
34
35
  import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
35
36
  import { searchFiles } from './fs-search.ts'
36
37
  import { extractFrameAncestors } from './browser-probe.ts'
@@ -351,6 +352,26 @@ function buildApi(
351
352
  }
352
353
  return { ok: true }
353
354
  },
355
+ // The tree row's rename: single-segment name, destination-existence and
356
+ // workspace-root refusals, link-aware (renames the row, not its target).
357
+ // fs-operations.ts owns the containment and shape rules.
358
+ 'fs.rename': async (payload) => {
359
+ const { cwd } = await cwdOf(payload)
360
+ return renameWorkspaceEntry({
361
+ cwd,
362
+ path: requireString(payload, 'path'),
363
+ name: requireString(payload, 'name'),
364
+ })
365
+ },
366
+ // The tree row's delete (permanent — the host has no trash): recursive
367
+ // for directories, unlinks a symlink row without touching its target.
368
+ 'fs.remove': async (payload) => {
369
+ const { cwd } = await cwdOf(payload)
370
+ return removeWorkspaceEntry({
371
+ cwd,
372
+ path: requireString(payload, 'path'),
373
+ })
374
+ },
354
375
  'git.worktrees': async (payload) => {
355
376
  const { cwd } = await gitCwdOf(payload)
356
377
  const selected = selectedRepoOf(payload)
@@ -438,6 +459,19 @@ function buildApi(
438
459
  const rev = requireString(payload, 'rev')
439
460
  return { content: await git.show(cwd, rev, path, repoRoot) }
440
461
  },
462
+ // Diff-fold expansion data: both sides' full file contents so the client
463
+ // can materialize the hidden context rows a -U3 hunk gap omitted. The
464
+ // sides resolve per diff kind (commit hash / staged / unstaged) inside
465
+ // git.foldContents; a missing side is null and the client degrades.
466
+ 'git.fold-contents': async (payload) => {
467
+ const { cwd } = await gitCwdOf(payload)
468
+ const repoRoot = selectedRepoOf(payload)
469
+ const record = payload as { path?: unknown; staged?: unknown; hash?: unknown }
470
+ const path = await resolveGitPath(cwd, requireString(record, 'path'), repoRoot)
471
+ const staged = record.staged === true
472
+ const hash = typeof record.hash === 'string' ? record.hash : undefined
473
+ return await git.foldContents(cwd, path, { staged, hash }, repoRoot)
474
+ },
441
475
  // Release a terminal immediately. The WebSocket close frame already does
442
476
  // this while the socket is open; this route covers the tab-close that
443
477
  // happens while the socket is down (reconnect loop), so a closed tab can
@@ -459,6 +493,15 @@ function buildApi(
459
493
  agentPtyRegistry?.close(uuid)
460
494
  return { ok: true }
461
495
  },
496
+ // The sidebar wait banner's skip button: abort every active
497
+ // terminal_wait_for on one agent terminal. Idempotent — 0 when nothing
498
+ // is waiting (a stale banner racing a wait that already resolved).
499
+ // Degraded mode (node-pty unavailable) has no registry and no waits: an
500
+ // honest ok.
501
+ 'agent-pty.skip-wait': (payload) => {
502
+ const uuid = requireString(payload, 'uuid')
503
+ return { ok: true, skipped: agentPtyRegistry?.skipWait(uuid) ?? 0 }
504
+ },
462
505
  // Terminal dependency status (issue #140): after a WS close 1011 with
463
506
  // reason `pty-deps-missing` the client fetches the full repair details
464
507
  // here — the close reason itself is capped at 123 bytes, too small for
@@ -472,6 +515,18 @@ function buildApi(
472
515
  // exists. Kill is fenced to the owning session by the jobs registry.
473
516
  'jobs.output': (payload) => jobsApi.output(payload),
474
517
  'jobs.kill': (payload) => jobsApi.kill(payload),
518
+ // The session lens: file operations the model performed in this session,
519
+ // parsed from the session's own event log (read-only replay — the
520
+ // model's job_output cursor is never touched). Cold sessions degrade to
521
+ // an empty list (only live sessions have an in-memory log on this host).
522
+ 'changes.ops': async (payload) => {
523
+ const sessionId = requireString(payload, 'sessionId')
524
+ const stored = ctx.sessions.get(sessionId)
525
+ const events = stored?.snapshotEvents !== undefined
526
+ ? stored.snapshotEvents() as unknown as Parameters<typeof sessionFileOps>[0]
527
+ : []
528
+ return { ops: sessionFileOps(events) }
529
+ },
475
530
  // Subagent live previews: one batch request per refresh; the route folds
476
531
  // the newest text/tool activity of every running child in the tree.
477
532
  'subagents.live': (payload) => subagentLiveApi.live(payload),