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
@@ -25,14 +25,16 @@ import { useSyncExternalStore } from 'react'
25
25
  import clsx from 'clsx'
26
26
  import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
27
27
  import type { Context, SidebarSessionList } from '../context-types.ts'
28
- import { appendToDraft } from './conversation-draft.ts'
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'
@@ -119,18 +121,21 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
119
121
  * pane; sessionId/cwd cover onReferenceFile). */
120
122
  interface TabContentProps extends TabContentMemoKey {
121
123
  onToggleDir: (path: string) => void
122
- onReferenceFile: (path: string) => void
124
+ onReferenceFile: (path: string, isDir: boolean) => void
123
125
  ctx: Context
124
126
  store: SidebarStore
125
127
  /** Fired before a topology node jumps to its child session (see Sidebar). */
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
@@ -498,8 +503,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
498
503
  * Subagent auto-activation: the moment the current conversation spawns its
499
504
  * FIRST direct subagent (a 0 → N transition on the list feed), the "auto
500
505
  * open" pref is on, and the Subagent tab type is enabled in settings,
501
- * open the panel (if collapsed) and focus the Subagent page
502
- * (single-instance: an existing tab is focused, never duplicated).
506
+ * focus the Subagent page (single-instance: an existing tab is focused,
507
+ * never duplicated). On wide viewports the right panel also expands; on
508
+ * narrow viewports background activity never forces the full-screen drawer
509
+ * open over the chat.
503
510
  * Switching to a session that already has subagents never triggers — its
504
511
  * baseline starts at the current count — so a deliberate layout is never
505
512
  * fought.
@@ -525,9 +532,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
525
532
  if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
526
533
  if (!store.getPrefs().autoOpenSubagent) return
527
534
  if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
528
- store.reduce(s => s.panelOpen ? s : togglePanel(s))
529
- // Pin the landing to the first pane: the auto-opened Subagent page
530
- // must appear in the panel that just expanded, not wherever the user
535
+ // Read the viewport when the delayed activation fires: a resize while
536
+ // the debounce is armed must not let background activity force the
537
+ // narrow full-screen drawer open over the chat.
538
+ if (!isNarrowWidth(window.innerWidth)) {
539
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
540
+ }
541
+ // Pin the landing to the first pane: the auto-activated Subagent page
542
+ // must be ready in the panel that just expanded, not wherever the user
531
543
  // last touched.
532
544
  store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
533
545
  ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
@@ -545,11 +557,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
545
557
  /**
546
558
  * Job auto-activation: the moment a NEW background job appears for the
547
559
  * current conversation (a job id the previous snapshot lacked), the
548
- * auto-open pref is on, and the Jobs tab type is enabled, open the panel
549
- * (if collapsed) and focus the Jobs page. Unlike the subagent trigger
550
- * (0 N only), ANY new job id triggers: the agent may start several
551
- * jobs in one session, and each should surface. A fresh page load never
552
- * triggers its baseline starts at the current snapshot.
560
+ * auto-open pref is on, and the Jobs tab type is enabled, focus the Jobs
561
+ * page. The right panel expands only on wide viewports background
562
+ * activity never forces the narrow full-screen drawer open. Unlike the
563
+ * subagent trigger (0 → N only), ANY new job id triggers: the agent may
564
+ * start several jobs in one session, and each should surface. A fresh page
565
+ * load never triggers — its baseline starts at the current snapshot.
553
566
  */
554
567
  const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
555
568
  useEffect(() => {
@@ -559,7 +572,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
559
572
  if (!detectNewJob(prev, sessionList, sessionId)) return
560
573
  if (!store.getPrefs().autoOpenJobs) return
561
574
  if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
562
- store.reduce(s => s.panelOpen ? s : togglePanel(s))
575
+ if (!isNarrowWidth(window.innerWidth)) {
576
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
577
+ }
563
578
  store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
564
579
  ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
565
580
  }, [sessionList, sessionId, store, ctx])
@@ -1128,19 +1143,63 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
1128
1143
  }, [actions, pinnedVirtualTabs, activePinnedTabId, store])
1129
1144
 
1130
1145
  /**
1131
- * The explorer's @-reference button: append `@<relative path>` to the
1132
- * session's composer draft (space-separated). The conversation service is
1133
- * resolved lazily through `ctx.get` (the inject-free read the app's own
1134
- * plugins read 'conversation' the same way); a missing service or scope
1135
- * degrades to a logged no-op, never a crash. Defined above the no-session
1136
- * early return a hook must never sit behind a conditional return
1137
- * (React counts hooks per render).
1146
+ * The explorer's @-reference button. Directories append the folder mention
1147
+ * (`@dir/`) as plain text so DSH's folder decoration and completion keep
1148
+ * working; files insert a structured chip like the native `@` picker, so
1149
+ * the whole reference stays one link instead of decorating only the
1150
+ * leading folder. Resolves the session-scope ctx and the conversation
1151
+ * input service at click time; a missing service or scope degrades to a
1152
+ * logged no-op, never a crash. Defined above the no-session early return
1153
+ * — a hook must never sit behind a conditional return (React counts hooks
1154
+ * per render).
1138
1155
  */
1139
- const referenceInChat = useCallback((path: string): void => {
1156
+ const referenceInChat = useCallback((path: string, isDir: boolean): void => {
1140
1157
  if (sessionId === undefined) return
1141
- appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
1158
+ const rel = relativeTo(cwd ?? '', path)
1159
+ if (isDir) {
1160
+ appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
1161
+ return
1162
+ }
1163
+ if (!insertFileReference(ctx, sessionId, rel)) {
1164
+ appendToDraft(ctx, sessionId, `@${rel}`)
1165
+ }
1142
1166
  }, [ctx, sessionId, cwd])
1143
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
+
1144
1203
  if (state === undefined || sessionId === undefined) {
1145
1204
  // Keep the unavailable controls focusable: touch users have no hover, so
1146
1205
  // focus is the only way the existing Tooltip can explain what is missing.
@@ -1186,6 +1245,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
1186
1245
  * strip must never break because a plugin's badge computation failed.
1187
1246
  */
1188
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
+ }
1189
1257
  const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
1190
1258
  if (descriptor?.badge === undefined) return null
1191
1259
  let value: string | number | null | undefined
@@ -1231,6 +1299,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
1231
1299
  revealed={state.revealed ?? []}
1232
1300
  onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
1233
1301
  onReferenceFile={referenceInChat}
1302
+ onPathRenamed={onPathRenamed}
1303
+ onPathRemoved={onPathRemoved}
1234
1304
  ctx={ctx}
1235
1305
  store={store}
1236
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. */
@@ -60,6 +61,13 @@ const FAILURE_LIMIT = 3
60
61
  */
61
62
  const PTY_DEPS_MISSING = 'pty-deps-missing'
62
63
 
64
+ /**
65
+ * The WS close-reason prefix the host sends when the CONFIGURED shell was
66
+ * not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
67
+ * literal in lockstep). The view renders a localized, actionable banner.
68
+ */
69
+ const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
70
+
63
71
  /** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
64
72
  type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
65
73
 
@@ -115,6 +123,27 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
115
123
  const [fatal, setFatal] = useState<string | null>(null)
116
124
  const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
117
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])
118
147
  const connectRef = useRef<(() => void) | null>(null)
119
148
 
120
149
  useEffect(() => {
@@ -241,6 +270,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
241
270
  })
242
271
  return
243
272
  }
273
+ // The configured shell could not be found (settings page or yaml):
274
+ // a localized banner beats the raw English close reason.
275
+ if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
276
+ setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
277
+ return
278
+ }
244
279
  // A server-side refusal carries a close code + reason; retrying it
245
280
  // forever would only spin the banner, so surface it with a retry.
246
281
  if (event.code === 1011 && event.reason !== '') {
@@ -361,6 +396,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
361
396
 
362
397
  return (
363
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
+ )}
364
405
  {depsFatal !== null && (
365
406
  <TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
366
407
  )}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
3
+ * agent-owned terminal's view while the model blocks in terminal_wait_for.
4
+ * The skip button asks the host to abort every active wait on the terminal
5
+ * (`agent-pty.skip-wait`); the banner disappears when the host's next
6
+ * agent-terminals push drops the waiting field — no optimistic UI. Kept in
7
+ * its own module (no xterm imports) so jsdom tests can render it directly.
8
+ */
9
+ import { t } from './locales.ts'
10
+ import css from './sidebar.module.css'
11
+
12
+ /** Cap the needle shown inline; the full text rides the title tooltip. */
13
+ const NEEDLE_DISPLAY_CAP = 80
14
+
15
+ /** Truncate one needle for inline display (title attr carries the full text). */
16
+ export function truncateNeedle(needle: string): string {
17
+ return needle.length > NEEDLE_DISPLAY_CAP ? `${needle.slice(0, NEEDLE_DISPLAY_CAP - 1)}…` : needle
18
+ }
19
+
20
+ export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
21
+ const { needle, onSkip } = props
22
+ return (
23
+ <div className={css.terminalWaitBanner}>
24
+ <span className={css.terminalWaitNeedle} title={needle}>
25
+ {t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
26
+ </span>
27
+ <button type="button" className={css.terminalRetry} onClick={onSkip}>
28
+ {t('terminalSkipWait')}
29
+ </button>
30
+ </div>
31
+ )
32
+ }
@@ -26,6 +26,7 @@ import { languageForPath } from './lang.ts'
26
26
  import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
27
27
  import { isDarkScheme, subscribeColorScheme } from './theme.ts'
28
28
  import { appendToDraft } from './conversation-draft.ts'
29
+ import { useSelectionPopup } from './selection-popup.ts'
29
30
  import { buildSelectionInsert } from './selection-payload.ts'
30
31
  import { t } from './locales.ts'
31
32
  import type { EditorToolbarControls, EditorToolbarState } from './service.ts'
@@ -50,13 +51,6 @@ export interface TextEditorProps {
50
51
  onToolbarControls?: (controls: EditorToolbarControls | null) => void
51
52
  }
52
53
 
53
- /** The floating "add to conversation" action: payload + viewport anchor. */
54
- interface SelectionPopup {
55
- insert: string
56
- left: number
57
- top: number
58
- }
59
-
60
54
  export function TextEditor(props: TextEditorProps) {
61
55
  const { ctx, scope, path, content, truncated } = props
62
56
  const [dirty, setDirty] = useState(false)
@@ -68,34 +62,21 @@ export function TextEditor(props: TextEditorProps) {
68
62
  const themeCompRef = useRef<CmThemeCompartment | null>(null)
69
63
  /** The app's resolved color scheme; the editor re-themes in place on flips. */
70
64
  const [dark, setDark] = useState(() => isDarkScheme())
71
- /** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
72
- const [popup, setPopup] = useState<SelectionPopup | null>(null)
73
- /** Live mirror of the popup state for click-time reads (no re-render race). */
74
- const popupRef = useRef<SelectionPopup | null>(null)
75
-
76
- const hidePopup = (): void => {
77
- popupRef.current = null
78
- setPopup(null)
79
- }
80
-
81
- /** Anchor the popup above the selection center; clamp inside the viewport. */
82
- const showPopup = (insert: string, left: number, top: number): void => {
83
- const next: SelectionPopup = {
84
- insert,
85
- left: Math.min(Math.max(left, 80), window.innerWidth - 80),
86
- top,
87
- }
88
- popupRef.current = next
89
- setPopup(next)
90
- }
91
-
92
- /** The popup button's click: insert the stored payload into the draft. */
93
- const commitPopup = (): void => {
94
- const current = popupRef.current
95
- if (current === null) return
96
- appendToDraft(ctx, scope.sessionId, current.insert)
97
- hidePopup()
98
- }
65
+ /**
66
+ * The floating "add to conversation" popup (viewport-anchored; null =
67
+ * hidden). The hook owns show/hide/commit plus the global dismissal
68
+ * listeners (outside mousedown, Escape, hidden tab/window, and the editor
69
+ * surface leaving the viewport — the tab-switch/panel-collapse paths have
70
+ * no DOM events of their own) see selection-popup.ts.
71
+ */
72
+ const selectionPopup = useSelectionPopup({
73
+ onCommit: (insert) => { appendToDraft(ctx, scope.sessionId, insert) },
74
+ // The surface that must stay on screen: the CodeMirror host. This
75
+ // component has no preview surface (file preview is retired); without
76
+ // the geometry signal a tab switch (display:none) or a panel collapse
77
+ // (translated off-screen) would leave the fixed portaled button behind.
78
+ getSurface: () => hostRef.current,
79
+ })
99
80
 
100
81
  useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
101
82
 
@@ -103,7 +84,7 @@ export function TextEditor(props: TextEditorProps) {
103
84
  useEffect(() => {
104
85
  setDirty(false)
105
86
  setSaveState('idle')
106
- hidePopup()
87
+ selectionPopup.hide()
107
88
  }, [content])
108
89
 
109
90
  // Create the CodeMirror editor once the content is loaded. The view owns
@@ -149,33 +130,33 @@ export function TextEditor(props: TextEditorProps) {
149
130
  // selection and hides it too.
150
131
  CodeMirrorView.updateListener.of((update) => {
151
132
  if (update.geometryChanged || update.viewportChanged) {
152
- hidePopup()
133
+ selectionPopup.hide()
153
134
  return
154
135
  }
155
136
  if (!update.view.hasFocus) {
156
- hidePopup()
137
+ selectionPopup.hide()
157
138
  return
158
139
  }
159
140
  if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
160
141
  const sel = update.state.selection.main
161
142
  if (sel.empty) {
162
- hidePopup()
143
+ selectionPopup.hide()
163
144
  return
164
145
  }
165
146
  const text = update.state.sliceDoc(sel.from, sel.to)
166
147
  if (text.trim() === '') {
167
- hidePopup()
148
+ selectionPopup.hide()
168
149
  return
169
150
  }
170
151
  // Page coordinates (the document root may scroll); the popup is
171
152
  // position:fixed, so convert to viewport coordinates.
172
153
  const rect = update.view.coordsAtPos(sel.head)
173
154
  if (rect === null) {
174
- hidePopup()
155
+ selectionPopup.hide()
175
156
  return
176
157
  }
177
158
  const doc = update.state.doc
178
- showPopup(
159
+ selectionPopup.show(
179
160
  buildSelectionInsert(path, scope.cwd, {
180
161
  start: doc.lineAt(sel.from).number,
181
162
  end: doc.lineAt(sel.to).number,
@@ -272,15 +253,16 @@ export function TextEditor(props: TextEditorProps) {
272
253
  <div className={css.editorCm} ref={hostRef} />
273
254
  </>
274
255
  )}
275
- {popup !== null && createPortal(
256
+ {selectionPopup.popup !== null && createPortal(
276
257
  <button
277
258
  type="button"
259
+ ref={selectionPopup.buttonRef}
278
260
  className={css.selectionPopup}
279
- style={{ left: popup.left, top: popup.top }}
261
+ style={{ left: selectionPopup.popup.left, top: selectionPopup.popup.top }}
280
262
  // Keep the selection (and CodeMirror focus) alive until the click
281
263
  // commits — without this the popup unmounts before click lands.
282
264
  onMouseDown={(event) => { event.preventDefault() }}
283
- onClick={commitPopup}
265
+ onClick={selectionPopup.commit}
284
266
  >
285
267
  {t('addToConversation')}
286
268
  </button>,
@@ -59,16 +59,34 @@ export function TreePanel(props: {
59
59
  openWithSsh?: boolean
60
60
  onOpenWith?: (targetId: string, path: string) => void
61
61
  onToggleOpenWithPin?: (targetId: string) => void
62
- onReferenceFile: (path: string) => void
62
+ onReferenceFile: (path: string, isDir: boolean) => void
63
+ /** Tree-row mutations (passed through to the file tree; absent → hidden). */
64
+ onPathRenamed?: (oldPath: string, newPath: string) => void
65
+ onPathRemoved?: (path: string) => void
63
66
  /** Full-window presentation: the panel fills its host instead of docking
64
67
  * at a fixed width. */
65
68
  full?: boolean
66
69
  }) {
67
- const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
70
+ const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathRemoved, full } = props
68
71
  const [query, setQuery] = useState('')
69
72
  const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
70
73
  const [error, setError] = useState<string | null>(null)
71
74
  const [refreshTick, setRefreshTick] = useState(0)
75
+
76
+ // The tree caches loaded directories per refresh tick, so content changed
77
+ // outside DSH (another editor, a sync tool) stays stale until the manual
78
+ // refresh click. Re-focusing the window bumps the tick automatically, and
79
+ // integrations can force a refresh by dispatching a bubbling
80
+ // `dsh-sidebar:refresh-files` event on `window`.
81
+ useEffect(() => {
82
+ const bump = (): void => { setRefreshTick(tick => tick + 1) }
83
+ window.addEventListener('focus', bump)
84
+ window.addEventListener('dsh-sidebar:refresh-files', bump)
85
+ return () => {
86
+ window.removeEventListener('focus', bump)
87
+ window.removeEventListener('dsh-sidebar:refresh-files', bump)
88
+ }
89
+ }, [])
72
90
  /** One-line upload status under the search row ('' hides the hint). */
73
91
  const [uploadStatus, setUploadStatus] = useState('')
74
92
  /** Whether the status line is a failure/cancel (error color, stays visible). */
@@ -233,6 +251,8 @@ export function TreePanel(props: {
233
251
  onOpenWith={onOpenWith}
234
252
  onToggleOpenWithPin={onToggleOpenWithPin}
235
253
  onReferenceFile={onReferenceFile}
254
+ onPathRenamed={onPathRenamed}
255
+ onPathRemoved={onPathRemoved}
236
256
  refreshTick={refreshTick}
237
257
  onUploadRequest={startUpload}
238
258
  busy={busy}
package/src/client/api.ts CHANGED
@@ -212,6 +212,14 @@ export const api = {
212
212
  call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
213
213
  fsWrite: (scope: SessionScope, path: string, content: string) =>
214
214
  call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
215
+ /** Rename one tree row within its directory (single-segment name; a
216
+ * destination-existence clash is a 409; symlink rows rename the link). */
217
+ fsRename: (scope: SessionScope, path: string, name: string) =>
218
+ call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
219
+ /** Delete one tree row permanently (recursive for directories; a symlink
220
+ * row unlinks the link only). */
221
+ fsRemove: (scope: SessionScope, path: string) =>
222
+ call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
215
223
  /** Upload one file's raw bytes into `dir` (keeps the folder tree via
216
224
  * `relativePath`); the host streams it under the session workspace. */
217
225
  uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
@@ -241,6 +249,14 @@ export const api = {
241
249
  /** Full patch text of one commit (diff display for the history rows). */
242
250
  gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
243
251
  call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
252
+ /** Both sides' full file contents for a diff-fold expansion; a missing
253
+ * side is null (untracked / deleted) and the view degrades the fold. */
254
+ gitFoldContents: (scope: SessionScope, opts: { path: string; staged?: boolean; hash?: string }, worktree?: string, signal?: AbortSignal) =>
255
+ call<{ old: string | null; new: string | null }>('git.fold-contents', gitPayload(scope, worktree, {
256
+ path: opts.path,
257
+ ...(opts.staged !== undefined ? { staged: opts.staged } : {}),
258
+ ...(opts.hash !== undefined ? { hash: opts.hash } : {}),
259
+ }), signal),
244
260
  /** Discard the worktree changes of one file (the index is untouched). */
245
261
  gitDiscard: (scope: SessionScope, path: string, worktree?: string) =>
246
262
  call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })),
@@ -258,6 +274,10 @@ export const api = {
258
274
  /** Release an agent terminal by uuid (tab closed while WS was down). */
259
275
  agentPtyClose: (uuid: string) =>
260
276
  call<{ ok: true }>('agent-pty.close', { uuid }),
277
+ /** Skip every active terminal_wait_for on one agent terminal (the wait
278
+ * banner's skip button). Idempotent: {skipped:0} when none is active. */
279
+ agentSkipWait: (uuid: string) =>
280
+ call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }),
261
281
  /** Terminal dependency status (issue #140): after a WS close 1011 with
262
282
  * reason `pty-deps-missing` the view fetches the full repair details here
263
283
  * (the close reason itself is capped at 123 bytes). */
@@ -48,7 +48,7 @@
48
48
  * client.js); an edit that does land while a core HMR happens is caught by
49
49
  * the ETag comparison on the next activation.
50
50
  */
51
- export type ChunkName = 'terminal' | 'editor'
51
+ export type ChunkName = 'terminal' | 'editor' | 'locale'
52
52
 
53
53
  /** The module exports a chunk factory provides (namespace-ish record). */
54
54
  export type ChunkExports = Record<string, unknown>
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Lazy chunk entry: the 19 non-zh/en dictionaries (ja/de/fr/…/zh-HK/MO/TW —
3
+ * ~640KB of source). The sidebar's own `t()` only ever consults zh/en plus
4
+ * the better-locale override store, so these dicts are needed ONLY when
5
+ * @huanlin/dsh-plugin-better-locale is installed: they register into its
6
+ * override store from there (see the client apply's better-locale
7
+ * integration). Built as `lib/client-locale.js` and fetched from the
8
+ * plugin's /sidebar/bundle route on first need — never import this module
9
+ * from the core bundle: it would drag every language back into the startup
10
+ * path.
11
+ */
12
+ import { ja } from '../locales-ja.ts'
13
+ import { de } from '../locales-de.ts'
14
+ import { fr } from '../locales-fr.ts'
15
+ import { pt } from '../locales-pt.ts'
16
+ import { ko } from '../locales-ko.ts'
17
+ import { ar } from '../locales-ar.ts'
18
+ import { hi } from '../locales-hi.ts'
19
+ import { id } from '../locales-id.ts'
20
+ import { tr } from '../locales-tr.ts'
21
+ import { vi } from '../locales-vi.ts'
22
+ import { th } from '../locales-th.ts'
23
+ import { ru } from '../locales-ru.ts'
24
+ import { it } from '../locales-it.ts'
25
+ import { nl } from '../locales-nl.ts'
26
+ import { sv } from '../locales-sv.ts'
27
+ import { pl } from '../locales-pl.ts'
28
+ import { zhHK } from '../locales-zh-HK.ts'
29
+ import { zhTW } from '../locales-zh-TW.ts'
30
+ import { zhMO } from '../locales-zh-MO.ts'
31
+ import type { CopyKey } from '../locales.ts'
32
+
33
+ /** Key-set check against zh (type-only import — erased before bundling, so
34
+ * this adds no runtime tie to the core bundle): a dictionary missing or
35
+ * adding a key fails the build instead of silently falling back to en. */
36
+ const checked = (dict: Record<CopyKey, string>): Record<string, string> => dict
37
+
38
+ /** Every non-zh/en dictionary keyed by its override id, ready for the
39
+ * better-locale store's `register(ns, dicts)`. */
40
+ export const localeDicts: Record<string, Record<string, string>> = {
41
+ ja: checked(ja),
42
+ de: checked(de),
43
+ fr: checked(fr),
44
+ pt: checked(pt),
45
+ ko: checked(ko),
46
+ ar: checked(ar),
47
+ hi: checked(hi),
48
+ id: checked(id),
49
+ tr: checked(tr),
50
+ vi: checked(vi),
51
+ th: checked(th),
52
+ ru: checked(ru),
53
+ it: checked(it),
54
+ nl: checked(nl),
55
+ sv: checked(sv),
56
+ pl: checked(pl),
57
+ 'zh-HK': checked(zhHK),
58
+ 'zh-TW': checked(zhTW),
59
+ 'zh-MO': checked(zhMO),
60
+ }