dsh-better-sidebar 0.10.0

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
@@ -0,0 +1,540 @@
1
+ /**
2
+ * The source-control panel: status list (staged vs unstaged), stage/unstage,
3
+ * commit with a message box, branch switch, and a VSCode-like history — rows
4
+ * carry branch decorations, author and relative time. Clicking a changed
5
+ * file or a history row opens a dedicated diff TAB (see {@link DiffTab}),
6
+ * placed below the git pane on first use. File rows and history rows open a
7
+ * right-click context menu with advanced operations (open in editor, discard,
8
+ * revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
9
+ * focus (no file watcher — KISS).
10
+ */
11
+ import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react'
12
+ import {
13
+ Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
14
+ IconTrashOutline16, Input, Menu, Modal, writeClipboard,
15
+ } from '@deepseek-ai/dsh-client-ui-primitives'
16
+ import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
17
+ import { api } from './api.ts'
18
+ import { relativeTo } from './paths.ts'
19
+ import { relativeTime, t } from './locales.ts'
20
+ import type { SidebarTab } from './state.ts'
21
+ import css from './sidebar.module.css'
22
+
23
+ /** The XY status letters a row badge shows (X = index, Y = worktree). */
24
+ function badgeOf(entry: GitStatusEntry): string {
25
+ const index = entry.xy[0]
26
+ const worktree = entry.xy[1]
27
+ if (index !== undefined && index !== ' ' && index !== '?') return index
28
+ if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
29
+ return '?'
30
+ }
31
+
32
+ /** Whether the entry carries STAGED (index) changes — the X letter is set. */
33
+ function isStagedEntry(entry: GitStatusEntry): boolean {
34
+ const index = entry.xy[0]
35
+ return index !== undefined && index !== ' ' && index !== '?'
36
+ }
37
+
38
+ /** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
39
+ * (untracked `??` counts as unstaged: it is a worktree-only change). A file
40
+ * with both letters set ('MM') lands in BOTH sections. */
41
+ function isUnstagedEntry(entry: GitStatusEntry): boolean {
42
+ if (entry.xy === '??') return true
43
+ const worktree = entry.xy[1]
44
+ return worktree !== undefined && worktree !== ' ' && worktree !== '?'
45
+ }
46
+
47
+ /** Whether the entry is untracked (`??`): git diff never includes it. */
48
+ function isUntracked(entry: GitStatusEntry): boolean {
49
+ return badgeOf(entry) === '?'
50
+ }
51
+
52
+ /** The last path segment (tab title for a file's diff). */
53
+ function baseName(path: string): string {
54
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
55
+ return at === -1 ? path : path.slice(at + 1)
56
+ }
57
+
58
+ /** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
59
+ function refNames(refs: string): string[] {
60
+ return [...new Set(
61
+ refs
62
+ .split(',')
63
+ .map(ref => ref.trim())
64
+ .filter(ref => ref !== '')
65
+ .map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
66
+ .map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
67
+ )]
68
+ }
69
+
70
+ /** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
71
+ interface ConfirmState {
72
+ title: string
73
+ description: string
74
+ confirmLabel: string
75
+ onConfirm: () => Promise<unknown>
76
+ }
77
+
78
+ /** History batch size: the log loads lazily in pages so a long history never
79
+ * floods the panel at once (the end of the log is reached by paging). */
80
+ const LOG_BATCH = 20
81
+
82
+ export function GitView(props: {
83
+ scope: SessionScope
84
+ onOpenFile: (path: string) => void
85
+ /** Open a diff tab (the shell places it below the git pane on first use). */
86
+ onOpenDiff: (tab: SidebarTab) => void
87
+ }) {
88
+ const { scope, onOpenFile, onOpenDiff } = props
89
+ const [status, setStatus] = useState<GitStatusResult | null>(null)
90
+ const [loading, setLoading] = useState(true)
91
+ const [error, setError] = useState<string | null>(null)
92
+ const [branchNames, setBranchNames] = useState<string[]>([])
93
+ const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
94
+ const [commitMsg, setCommitMsg] = useState('')
95
+ const [busy, setBusy] = useState(false)
96
+ const [commitError, setCommitError] = useState<string | null>(null)
97
+ /** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
98
+ const [logEnded, setLogEnded] = useState(false)
99
+ const [logLoadingMore, setLogLoadingMore] = useState(false)
100
+
101
+ /** The open file-row context menu (cursor position for the portaled Menu). */
102
+ const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
103
+ /** The open history-row context menu. */
104
+ const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
105
+ /** The pending destructive action awaiting confirmation. */
106
+ const [confirm, setConfirm] = useState<ConfirmState | null>(null)
107
+
108
+ const refresh = useCallback(async (): Promise<void> => {
109
+ setLoading(true)
110
+ setError(null)
111
+ try {
112
+ const [statusResult, branchResult, logResult] = await Promise.all([
113
+ api.gitStatus(scope),
114
+ api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
115
+ // The first history page only; the rest arrives via "load more".
116
+ api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
117
+ ])
118
+ setStatus(statusResult)
119
+ setBranchNames(branchResult.names)
120
+ setLogEntries(logResult)
121
+ setLogEnded(logResult.length < LOG_BATCH)
122
+ } catch (reason) {
123
+ setError(reason instanceof Error ? reason.message : String(reason))
124
+ } finally {
125
+ setLoading(false)
126
+ }
127
+ }, [scope.sessionId, scope.cwd])
128
+
129
+ useEffect(() => { void refresh() }, [refresh])
130
+
131
+ /** Append the next history page (lazy: only when the user asks for more). */
132
+ const loadMoreLog = async (): Promise<void> => {
133
+ if (logLoadingMore || logEnded) return
134
+ setLogLoadingMore(true)
135
+ try {
136
+ const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
137
+ setLogEntries(entries => [...entries, ...next])
138
+ if (next.length < LOG_BATCH) setLogEnded(true)
139
+ } catch (reason) {
140
+ setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
141
+ } finally {
142
+ setLogLoadingMore(false)
143
+ }
144
+ }
145
+
146
+ /** The diff tab for one changed file (one tab per path+side; same id = focused). */
147
+ const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
148
+ onOpenDiff({
149
+ id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
150
+ type: 'diff',
151
+ title: baseName(entry.path),
152
+ diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
153
+ })
154
+ }
155
+
156
+ /** The diff tab for one commit (one tab per commit). */
157
+ const openCommitDiff = (entry: GitLogEntry): void => {
158
+ onOpenDiff({
159
+ id: `diff:c:${entry.hashFull}`,
160
+ type: 'diff',
161
+ title: `${entry.hash} ${entry.subject}`,
162
+ diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
163
+ })
164
+ }
165
+
166
+ const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
167
+ setBusy(true)
168
+ try {
169
+ if (staged) await api.gitUnstage(scope, entry.path)
170
+ else await api.gitStage(scope, entry.path)
171
+ await refresh()
172
+ } finally {
173
+ setBusy(false)
174
+ }
175
+ }
176
+
177
+ const stageAll = async (staged: boolean): Promise<void> => {
178
+ setBusy(true)
179
+ try {
180
+ if (staged) await api.gitUnstage(scope)
181
+ else await api.gitStage(scope)
182
+ await refresh()
183
+ } finally {
184
+ setBusy(false)
185
+ }
186
+ }
187
+
188
+ const commit = async (): Promise<void> => {
189
+ const message = commitMsg.trim()
190
+ if (message === '' || busy) return
191
+ setBusy(true)
192
+ setCommitError(null)
193
+ try {
194
+ await api.gitCommit(scope, message)
195
+ setCommitMsg('')
196
+ await refresh()
197
+ } catch (reason) {
198
+ setCommitError(reason instanceof Error ? reason.message : String(reason))
199
+ } finally {
200
+ setBusy(false)
201
+ }
202
+ }
203
+
204
+ const checkout = async (branch: string): Promise<void> => {
205
+ if (branch === status?.branch || busy) return
206
+ setBusy(true)
207
+ setCommitError(null)
208
+ try {
209
+ await api.gitCheckout(scope, branch)
210
+ await refresh()
211
+ } catch (reason) {
212
+ setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
213
+ } finally {
214
+ setBusy(false)
215
+ }
216
+ }
217
+
218
+ /** Run one destructive operation after the confirm modal, then refresh. */
219
+ const runConfirmed = (confirmState: ConfirmState): void => {
220
+ setConfirm({ ...confirmState, onConfirm: async () => {
221
+ setBusy(true)
222
+ setCommitError(null)
223
+ try {
224
+ await confirmState.onConfirm()
225
+ await refresh()
226
+ } catch (reason) {
227
+ setCommitError(reason instanceof Error ? reason.message : String(reason))
228
+ } finally {
229
+ setBusy(false)
230
+ }
231
+ } })
232
+ }
233
+
234
+ /** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
235
+ const copy = (text: string): void => {
236
+ void writeClipboard(text)
237
+ }
238
+
239
+ const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
240
+ event.preventDefault()
241
+ event.stopPropagation()
242
+ setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
243
+ }
244
+
245
+ const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
246
+ event.preventDefault()
247
+ event.stopPropagation()
248
+ setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
249
+ }
250
+
251
+ const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
252
+ const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
253
+
254
+ const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
255
+ return (
256
+ <div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
257
+ <button
258
+ type="button"
259
+ className={css.gitRowMain}
260
+ title={entry.path}
261
+ onClick={() => { openWorktreeDiff(entry, staged) }}
262
+ onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
263
+ >
264
+ <span className={css.gitBadge}>{badgeOf(entry)}</span>
265
+ <span className={css.gitName}>{entry.path}</span>
266
+ </button>
267
+ <button
268
+ type="button"
269
+ className={css.iconButton}
270
+ aria-label={staged ? t('unstage') : t('stage')}
271
+ title={staged ? t('unstage') : t('stage')}
272
+ disabled={busy}
273
+ onClick={() => { void stageEntry(entry, staged) }}
274
+ >
275
+ {staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
276
+ </button>
277
+ </div>
278
+ )
279
+ }
280
+
281
+ return (
282
+ <div className={css.git}>
283
+ <div className={css.gitHeader}>
284
+ <select
285
+ className={css.gitBranchSelect}
286
+ value={status?.branch ?? ''}
287
+ onChange={(event) => { void checkout(event.target.value) }}
288
+ disabled={busy || (status !== null && !status.isRepo)}
289
+ >
290
+ {(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
291
+ {branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
292
+ </select>
293
+ <button
294
+ type="button"
295
+ className={css.iconButton}
296
+ aria-label={t('refresh')}
297
+ title={t('refresh')}
298
+ onClick={() => { void refresh() }}
299
+ >
300
+ <IconRefreshOutline16 />
301
+ </button>
302
+ </div>
303
+
304
+ {loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
305
+ {!loading && error !== null && <div className={css.gitError}>{error}</div>}
306
+ {!loading && status !== null && !status.isRepo && (
307
+ <div className={css.gitPlaceholder}>{t('notRepo')}</div>
308
+ )}
309
+
310
+ {status !== null && status.isRepo && (
311
+ <>
312
+ <div className={css.gitSection}>
313
+ <div className={css.gitSectionHeader}>
314
+ <span>{t('staged')} ({stagedEntries.length})</span>
315
+ {stagedEntries.length > 0 && (
316
+ <button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
317
+ {t('unstageAll')}
318
+ </button>
319
+ )}
320
+ </div>
321
+ {stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
322
+ {stagedEntries.map(entry => renderEntry(entry, true))}
323
+ </div>
324
+ <div className={css.gitSection}>
325
+ <div className={css.gitSectionHeader}>
326
+ <span>{t('unstaged')} ({unstagedEntries.length})</span>
327
+ {unstagedEntries.length > 0 && (
328
+ <button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
329
+ {t('stageAll')}
330
+ </button>
331
+ )}
332
+ </div>
333
+ {unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
334
+ {unstagedEntries.map(entry => renderEntry(entry, false))}
335
+ </div>
336
+
337
+ <div className={css.gitCommit}>
338
+ <Input
339
+ className={css.gitCommitInput}
340
+ placeholder={t('commitPlaceholder')}
341
+ value={commitMsg}
342
+ disabled={busy}
343
+ onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
344
+ onKeyDown={(event) => {
345
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
346
+ }}
347
+ />
348
+ <button
349
+ type="button"
350
+ className={css.gitCommitButton}
351
+ disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
352
+ onClick={() => { void commit() }}
353
+ >
354
+ {t('commit')}
355
+ </button>
356
+ </div>
357
+ {commitError !== null && <div className={css.gitError}>{commitError}</div>}
358
+
359
+ <div className={css.gitSection}>
360
+ <div className={css.gitSectionHeader}><span>{t('history')}</span></div>
361
+ {logEntries.map(entry => (
362
+ <div
363
+ key={entry.hashFull}
364
+ role="button"
365
+ tabIndex={0}
366
+ className={css.gitLogRow}
367
+ title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
368
+ onClick={() => { openCommitDiff(entry) }}
369
+ onKeyDown={(event) => {
370
+ if (event.key === 'Enter' || event.key === ' ') {
371
+ event.preventDefault()
372
+ openCommitDiff(entry)
373
+ }
374
+ }}
375
+ onContextMenu={(event) => { openHistoryMenu(event, entry) }}
376
+ >
377
+ <span className={css.gitLogLine1}>
378
+ <span className={css.gitLogHash}>{entry.hash}</span>
379
+ <span className={css.gitLogSubject}>{entry.subject}</span>
380
+ </span>
381
+ <span className={css.gitLogLine2}>
382
+ {refNames(entry.refs).map(ref => (
383
+ <span key={ref} className={css.gitLogRef}>{ref}</span>
384
+ ))}
385
+ <span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
386
+ </span>
387
+ </div>
388
+ ))}
389
+ {!logEnded && (
390
+ <button
391
+ type="button"
392
+ className={css.gitLogMore}
393
+ disabled={logLoadingMore || busy}
394
+ onClick={() => { void loadMoreLog() }}
395
+ >
396
+ {logLoadingMore ? t('loading') : t('loadMore')}
397
+ </button>
398
+ )}
399
+ </div>
400
+
401
+ {/*
402
+ The one shared file-row context menu, positioned at the right-click
403
+ cursor (portal so the panel's overflow clip cannot crop it).
404
+ */}
405
+ <Menu
406
+ open={fileMenu !== null}
407
+ onClose={() => { setFileMenu(null) }}
408
+ items={[
409
+ { id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> },
410
+ fileMenu?.staged === true
411
+ ? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
412
+ : { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
413
+ ...(fileMenu !== null && !isUntracked(fileMenu.entry)
414
+ ? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
415
+ : []),
416
+ { type: 'separator', id: 'sep1' },
417
+ { id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
418
+ { id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
419
+ ]}
420
+ onSelect={(id) => {
421
+ const target = fileMenu
422
+ if (target === null) return
423
+ setFileMenu(null)
424
+ if (id === 'open') {
425
+ onOpenFile(target.entry.path)
426
+ return
427
+ }
428
+ if (id === 'stage') {
429
+ void stageEntry(target.entry, target.staged)
430
+ return
431
+ }
432
+ if (id === 'discard') {
433
+ runConfirmed({
434
+ title: t('discardTitle'),
435
+ description: t('discardDesc', { path: target.entry.path }),
436
+ confirmLabel: t('discard'),
437
+ onConfirm: () => api.gitDiscard(scope, target.entry.path),
438
+ })
439
+ return
440
+ }
441
+ if (id === 'relative') {
442
+ copy(relativeTo(scope.cwd ?? '', target.entry.path))
443
+ return
444
+ }
445
+ if (id === 'absolute') copy(target.entry.path)
446
+ }}
447
+ portal
448
+ align="start"
449
+ getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
450
+ anchor={<span />}
451
+ />
452
+
453
+ {/* The shared history-row context menu. */}
454
+ <Menu
455
+ open={historyMenu !== null}
456
+ onClose={() => { setHistoryMenu(null) }}
457
+ items={[
458
+ { id: 'view', label: t('viewCommitDiff') },
459
+ { id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
460
+ { id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
461
+ { id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
462
+ { type: 'separator', id: 'sep2' },
463
+ { id: 'revert', label: t('revertCommit'), danger: true },
464
+ { id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
465
+ ]}
466
+ onSelect={(id) => {
467
+ const target = historyMenu
468
+ if (target === null) return
469
+ setHistoryMenu(null)
470
+ if (id === 'view') {
471
+ openCommitDiff(target.entry)
472
+ return
473
+ }
474
+ if (id === 'copyShort') {
475
+ copy(target.entry.hash)
476
+ return
477
+ }
478
+ if (id === 'copyFull') {
479
+ copy(target.entry.hashFull)
480
+ return
481
+ }
482
+ if (id === 'copySubject') {
483
+ copy(target.entry.subject)
484
+ return
485
+ }
486
+ if (id === 'revert') {
487
+ runConfirmed({
488
+ title: t('revertTitle'),
489
+ description: t('revertDesc', { subject: target.entry.subject }),
490
+ confirmLabel: t('revertCommit'),
491
+ onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
492
+ })
493
+ return
494
+ }
495
+ if (id === 'cherryPick') {
496
+ runConfirmed({
497
+ title: t('cherryPickTitle'),
498
+ description: t('cherryPickDesc', { subject: target.entry.subject }),
499
+ confirmLabel: t('cherryPickCommit'),
500
+ onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
501
+ })
502
+ }
503
+ }}
504
+ portal
505
+ align="start"
506
+ getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
507
+ anchor={<span />}
508
+ />
509
+
510
+ {/* Destructive actions land here first: Cancel / Confirm. */}
511
+ <Modal
512
+ open={confirm !== null}
513
+ onClose={() => { setConfirm(null) }}
514
+ title={confirm?.title ?? ''}
515
+ closeLabel={t('cancel')}
516
+ footer={(
517
+ <>
518
+ <Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
519
+ <Button
520
+ variant="primary"
521
+ disabled={busy}
522
+ onClick={() => {
523
+ const pending = confirm
524
+ if (pending === null) return
525
+ setConfirm(null)
526
+ void pending.onConfirm()
527
+ }}
528
+ >
529
+ {confirm?.confirmLabel ?? ''}
530
+ </Button>
531
+ </>
532
+ )}
533
+ >
534
+ <p className={css.gitConfirmDesc}>{confirm?.description}</p>
535
+ </Modal>
536
+ </>
537
+ )}
538
+ </div>
539
+ )
540
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Placeholder shown when a persisted tab's type is not registered (the
3
+ * contributing plugin is not installed / not yet loaded). The tab is kept
4
+ * in state so it can recover if the plugin loads later; the user can close
5
+ * it through the tab bar's X button as usual. This is the graceful-
6
+ * degradation path for the open tab-type set: {@link Sidebar} renders this
7
+ * instead of crashing on an unknown type.
8
+ */
9
+ import type { ReactNode } from 'react'
10
+ import { t } from './locales.ts'
11
+ import css from './sidebar.module.css'
12
+ import type { TabComponentProps } from './service.ts'
13
+
14
+ export function OrphanedTab(props: TabComponentProps): ReactNode {
15
+ const { tab } = props
16
+ return (
17
+ <div className={css.editor}>
18
+ <div className={css.editorHeader}>
19
+ <span className={css.editorTitle} title={tab.type}>{tab.title}</span>
20
+ </div>
21
+ <div className={css.editorPlaceholder}>
22
+ <span>{t('pluginNotLoaded')}</span>
23
+ <code className={css.orphanedType}>{tab.type}</code>
24
+ </div>
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,110 @@
1
+ /** Browser-native PDF preview with an always-available download fallback. */
2
+ import { useEffect, useRef, useState } from 'react'
3
+ import clsx from 'clsx'
4
+ import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
5
+ import { t } from './locales.ts'
6
+ import css from './sidebar.module.css'
7
+
8
+ export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
9
+ const { scope, path, title } = props
10
+ const [load, setLoad] = useState<
11
+ | { status: 'loading' }
12
+ | { status: 'ready'; url: string }
13
+ | { status: 'error'; message: string }
14
+ >({ status: 'loading' })
15
+ const [interactionBlocked, setInteractionBlocked] = useState(false)
16
+ const frameRef = useRef<HTMLIFrameElement>(null)
17
+ const shieldRef = useRef<HTMLDivElement>(null)
18
+
19
+ useEffect(() => {
20
+ const controller = new AbortController()
21
+ let objectUrl: string | undefined
22
+ setLoad({ status: 'loading' })
23
+ void (async () => {
24
+ try {
25
+ const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
26
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
27
+ const bytes = await response.arrayBuffer()
28
+ if (controller.signal.aborted) return
29
+ // A direct iframe navigation may download when an old host process or
30
+ // proxy cached application/octet-stream. The Blob URL owns an explicit
31
+ // PDF MIME and therefore consistently opens the browser PDF viewer.
32
+ objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
33
+ setLoad({ status: 'ready', url: objectUrl })
34
+ } catch (error) {
35
+ if (controller.signal.aborted) return
36
+ setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
37
+ }
38
+ })()
39
+ return () => {
40
+ controller.abort()
41
+ if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
42
+ }
43
+ }, [scope.sessionId, scope.cwd, path])
44
+
45
+ useEffect(() => {
46
+ const block = (): void => {
47
+ setInteractionBlocked(true)
48
+ if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
49
+ if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
50
+ }
51
+ const unblock = (): void => {
52
+ setInteractionBlocked(false)
53
+ if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
54
+ if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
55
+ }
56
+ const blockForResize = (event: PointerEvent): void => {
57
+ const target = event.target
58
+ if (target instanceof Element
59
+ && target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
60
+ block()
61
+ }
62
+ }
63
+ // An iframe is a separate browsing context and otherwise swallows the
64
+ // dragover/pointer stream as soon as the cursor crosses it. Temporarily
65
+ // remove it from hit-testing while a tab drag or pane resize is active.
66
+ document.addEventListener('dragstart', block, true)
67
+ document.addEventListener('dragend', unblock, true)
68
+ document.addEventListener('drop', unblock, true)
69
+ window.addEventListener('pointerdown', blockForResize, true)
70
+ window.addEventListener('pointerup', unblock, true)
71
+ window.addEventListener('pointercancel', unblock, true)
72
+ window.addEventListener('blur', unblock)
73
+ return () => {
74
+ document.removeEventListener('dragstart', block, true)
75
+ document.removeEventListener('dragend', unblock, true)
76
+ document.removeEventListener('drop', unblock, true)
77
+ window.removeEventListener('pointerdown', blockForResize, true)
78
+ window.removeEventListener('pointerup', unblock, true)
79
+ window.removeEventListener('pointercancel', unblock, true)
80
+ window.removeEventListener('blur', unblock)
81
+ }
82
+ }, [])
83
+
84
+ return (
85
+ <div className={css.editorPdf}>
86
+ <div className={css.editorPdfToolbar}>
87
+ <a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
88
+ {t('downloadToView')}
89
+ </a>
90
+ </div>
91
+ <div className={css.editorPdfStage}>
92
+ {load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
93
+ {load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
94
+ {load.status === 'ready' && (
95
+ <iframe
96
+ ref={frameRef}
97
+ className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
98
+ src={load.url}
99
+ title={title}
100
+ />
101
+ )}
102
+ <div
103
+ ref={shieldRef}
104
+ className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
105
+ aria-hidden="true"
106
+ />
107
+ </div>
108
+ </div>
109
+ )
110
+ }