@keith9681/dsh-sidebar 0.15.2

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -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 +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. package/src/wire.ts +100 -0
@@ -0,0 +1,558 @@
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 { isSessionEditable, subscribeSessionEditable } from './editable-state.ts'
19
+ import { relativeTo } from './paths.ts'
20
+ import { relativeTime, t } from './locales.ts'
21
+ import type { SidebarTab } from './state.ts'
22
+ import css from './sidebar.module.css'
23
+
24
+ /** The XY status letters a row badge shows (X = index, Y = worktree). */
25
+ function badgeOf(entry: GitStatusEntry): string {
26
+ const index = entry.xy[0]
27
+ const worktree = entry.xy[1]
28
+ if (index !== undefined && index !== ' ' && index !== '?') return index
29
+ if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
30
+ return '?'
31
+ }
32
+
33
+ /** Whether the entry carries STAGED (index) changes — the X letter is set. */
34
+ function isStagedEntry(entry: GitStatusEntry): boolean {
35
+ const index = entry.xy[0]
36
+ return index !== undefined && index !== ' ' && index !== '?'
37
+ }
38
+
39
+ /** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
40
+ * (untracked `??` counts as unstaged: it is a worktree-only change). A file
41
+ * with both letters set ('MM') lands in BOTH sections. */
42
+ function isUnstagedEntry(entry: GitStatusEntry): boolean {
43
+ if (entry.xy === '??') return true
44
+ const worktree = entry.xy[1]
45
+ return worktree !== undefined && worktree !== ' ' && worktree !== '?'
46
+ }
47
+
48
+ /** Whether the entry is untracked (`??`): git diff never includes it. */
49
+ function isUntracked(entry: GitStatusEntry): boolean {
50
+ return badgeOf(entry) === '?'
51
+ }
52
+
53
+ /** The last path segment (tab title for a file's diff). */
54
+ function baseName(path: string): string {
55
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
56
+ return at === -1 ? path : path.slice(at + 1)
57
+ }
58
+
59
+ /** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
60
+ function refNames(refs: string): string[] {
61
+ return [...new Set(
62
+ refs
63
+ .split(',')
64
+ .map(ref => ref.trim())
65
+ .filter(ref => ref !== '')
66
+ .map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
67
+ .map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
68
+ )]
69
+ }
70
+
71
+ /** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
72
+ interface ConfirmState {
73
+ title: string
74
+ description: string
75
+ confirmLabel: string
76
+ onConfirm: () => Promise<unknown>
77
+ }
78
+
79
+ /** History batch size: the log loads lazily in pages so a long history never
80
+ * floods the panel at once (the end of the log is reached by paging). */
81
+ const LOG_BATCH = 20
82
+
83
+ export function GitView(props: {
84
+ scope: SessionScope
85
+ onOpenFile: (path: string) => void
86
+ /** Open a diff tab (the shell places it below the git pane on first use). */
87
+ onOpenDiff: (tab: SidebarTab) => void
88
+ }) {
89
+ const { scope, onOpenFile, onOpenDiff } = props
90
+ const [status, setStatus] = useState<GitStatusResult | null>(null)
91
+ const [loading, setLoading] = useState(true)
92
+ const [error, setError] = useState<string | null>(null)
93
+ const [branchNames, setBranchNames] = useState<string[]>([])
94
+ const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
95
+ const [commitMsg, setCommitMsg] = useState('')
96
+ const [busy, setBusy] = useState(false)
97
+ const [commitError, setCommitError] = useState<string | null>(null)
98
+ /** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
99
+ const [logEnded, setLogEnded] = useState(false)
100
+ const [logLoadingMore, setLogLoadingMore] = useState(false)
101
+
102
+ /** The open file-row context menu (cursor position for the portaled Menu). */
103
+ const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
104
+ /** The open history-row context menu. */
105
+ const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
106
+ /** The pending destructive action awaiting confirmation. */
107
+ const [confirm, setConfirm] = useState<ConfirmState | null>(null)
108
+
109
+ /** Whether THIS session may write (read-only by default; the editor toolbar toggle flips it). */
110
+ const [writable, setWritable] = useState(() => isSessionEditable(scope.sessionId))
111
+
112
+ // Follow the shared per-session write permission so the git panel and the
113
+ // editor toolbar stay in lockstep.
114
+ useEffect(() => subscribeSessionEditable(scope.sessionId, setWritable), [scope.sessionId])
115
+
116
+ const refresh = useCallback(async (): Promise<void> => {
117
+ setLoading(true)
118
+ setError(null)
119
+ try {
120
+ const [statusResult, branchResult, logResult] = await Promise.all([
121
+ api.gitStatus(scope),
122
+ api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
123
+ // The first history page only; the rest arrives via "load more".
124
+ api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
125
+ ])
126
+ setStatus(statusResult)
127
+ setBranchNames(branchResult.names)
128
+ setLogEntries(logResult)
129
+ setLogEnded(logResult.length < LOG_BATCH)
130
+ } catch (reason) {
131
+ setError(reason instanceof Error ? reason.message : String(reason))
132
+ } finally {
133
+ setLoading(false)
134
+ }
135
+ }, [scope.sessionId, scope.cwd])
136
+
137
+ useEffect(() => { void refresh() }, [refresh])
138
+
139
+ /** Append the next history page (lazy: only when the user asks for more). */
140
+ const loadMoreLog = async (): Promise<void> => {
141
+ if (logLoadingMore || logEnded) return
142
+ setLogLoadingMore(true)
143
+ try {
144
+ const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
145
+ setLogEntries(entries => [...entries, ...next])
146
+ if (next.length < LOG_BATCH) setLogEnded(true)
147
+ } catch (reason) {
148
+ setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
149
+ } finally {
150
+ setLogLoadingMore(false)
151
+ }
152
+ }
153
+
154
+ /** The diff tab for one changed file (one tab per path+side; same id = focused). */
155
+ const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
156
+ onOpenDiff({
157
+ id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
158
+ type: 'diff',
159
+ title: baseName(entry.path),
160
+ diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
161
+ })
162
+ }
163
+
164
+ /** The diff tab for one commit (one tab per commit). */
165
+ const openCommitDiff = (entry: GitLogEntry): void => {
166
+ onOpenDiff({
167
+ id: `diff:c:${entry.hashFull}`,
168
+ type: 'diff',
169
+ title: `${entry.hash} ${entry.subject}`,
170
+ diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
171
+ })
172
+ }
173
+
174
+ const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
175
+ if (!writable) return
176
+ setBusy(true)
177
+ try {
178
+ if (staged) await api.gitUnstage(scope, entry.path)
179
+ else await api.gitStage(scope, entry.path)
180
+ await refresh()
181
+ } finally {
182
+ setBusy(false)
183
+ }
184
+ }
185
+
186
+ const stageAll = async (staged: boolean): Promise<void> => {
187
+ if (!writable) return
188
+ setBusy(true)
189
+ try {
190
+ if (staged) await api.gitUnstage(scope)
191
+ else await api.gitStage(scope)
192
+ await refresh()
193
+ } finally {
194
+ setBusy(false)
195
+ }
196
+ }
197
+
198
+ const commit = async (): Promise<void> => {
199
+ const message = commitMsg.trim()
200
+ if (message === '' || busy || !writable) return
201
+ setBusy(true)
202
+ setCommitError(null)
203
+ try {
204
+ await api.gitCommit(scope, message)
205
+ setCommitMsg('')
206
+ await refresh()
207
+ } catch (reason) {
208
+ setCommitError(reason instanceof Error ? reason.message : String(reason))
209
+ } finally {
210
+ setBusy(false)
211
+ }
212
+ }
213
+
214
+ const checkout = async (branch: string): Promise<void> => {
215
+ if (branch === status?.branch || busy || !writable) return
216
+ setBusy(true)
217
+ setCommitError(null)
218
+ try {
219
+ await api.gitCheckout(scope, branch)
220
+ await refresh()
221
+ } catch (reason) {
222
+ setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
223
+ } finally {
224
+ setBusy(false)
225
+ }
226
+ }
227
+
228
+ /** Run one destructive operation after the confirm modal, then refresh. */
229
+ const runConfirmed = (confirmState: ConfirmState): void => {
230
+ setConfirm({ ...confirmState, onConfirm: async () => {
231
+ setBusy(true)
232
+ setCommitError(null)
233
+ try {
234
+ await confirmState.onConfirm()
235
+ await refresh()
236
+ } catch (reason) {
237
+ setCommitError(reason instanceof Error ? reason.message : String(reason))
238
+ } finally {
239
+ setBusy(false)
240
+ }
241
+ } })
242
+ }
243
+
244
+ /** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
245
+ const copy = (text: string): void => {
246
+ void writeClipboard(text)
247
+ }
248
+
249
+ const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
250
+ event.preventDefault()
251
+ event.stopPropagation()
252
+ setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
253
+ }
254
+
255
+ const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
256
+ event.preventDefault()
257
+ event.stopPropagation()
258
+ setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
259
+ }
260
+
261
+ const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
262
+ const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
263
+
264
+ const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
265
+ return (
266
+ <div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
267
+ <button
268
+ type="button"
269
+ className={css.gitRowMain}
270
+ title={entry.path}
271
+ onClick={() => { openWorktreeDiff(entry, staged) }}
272
+ onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
273
+ >
274
+ <span className={css.gitBadge}>{badgeOf(entry)}</span>
275
+ <span className={css.gitName}>{entry.path}</span>
276
+ </button>
277
+ <button
278
+ type="button"
279
+ className={css.iconButton}
280
+ aria-label={staged ? t('unstage') : t('stage')}
281
+ title={staged ? t('unstage') : t('stage')}
282
+ disabled={busy || !writable}
283
+ onClick={() => { void stageEntry(entry, staged) }}
284
+ >
285
+ {staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
286
+ </button>
287
+ </div>
288
+ )
289
+ }
290
+
291
+ return (
292
+ <div className={css.git}>
293
+ <div className={css.gitHeader}>
294
+ <select
295
+ className={css.gitBranchSelect}
296
+ value={status?.branch ?? ''}
297
+ onChange={(event) => { void checkout(event.target.value) }}
298
+ disabled={busy || !writable || (status !== null && !status.isRepo)}
299
+ >
300
+ {(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
301
+ {branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
302
+ </select>
303
+ <button
304
+ type="button"
305
+ className={css.iconButton}
306
+ aria-label={t('refresh')}
307
+ title={t('refresh')}
308
+ onClick={() => { void refresh() }}
309
+ >
310
+ <IconRefreshOutline16 size={14} />
311
+ </button>
312
+ </div>
313
+
314
+ {loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
315
+ {!loading && error !== null && <div className={css.gitError}>{error}</div>}
316
+ {!loading && status !== null && !status.isRepo && (
317
+ <div className={css.gitPlaceholder}>{t('notRepo')}</div>
318
+ )}
319
+
320
+ {status !== null && status.isRepo && (
321
+ <>
322
+ <div className={css.gitSection}>
323
+ <div className={css.gitSectionHeader}>
324
+ <span>{t('staged')} ({stagedEntries.length})</span>
325
+ {stagedEntries.length > 0 && (
326
+ <button type="button" className={css.gitLink} disabled={busy || !writable} onClick={() => { void stageAll(true) }}>
327
+ {t('unstageAll')}
328
+ </button>
329
+ )}
330
+ </div>
331
+ {stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
332
+ {stagedEntries.map(entry => renderEntry(entry, true))}
333
+ </div>
334
+ <div className={css.gitSection}>
335
+ <div className={css.gitSectionHeader}>
336
+ <span>{t('unstaged')} ({unstagedEntries.length})</span>
337
+ {unstagedEntries.length > 0 && (
338
+ <button type="button" className={css.gitLink} disabled={busy || !writable} onClick={() => { void stageAll(false) }}>
339
+ {t('stageAll')}
340
+ </button>
341
+ )}
342
+ </div>
343
+ {unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
344
+ {unstagedEntries.map(entry => renderEntry(entry, false))}
345
+ </div>
346
+
347
+ <div className={css.gitCommit}>
348
+ <Input
349
+ className={css.gitCommitInput}
350
+ placeholder={t('commitPlaceholder')}
351
+ value={commitMsg}
352
+ disabled={busy || !writable}
353
+ onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
354
+ onKeyDown={(event) => {
355
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
356
+ }}
357
+ />
358
+ <button
359
+ type="button"
360
+ className={css.gitCommitButton}
361
+ disabled={busy || !writable || commitMsg.trim() === '' || stagedEntries.length === 0}
362
+ onClick={() => { void commit() }}
363
+ >
364
+ {t('commit')}
365
+ </button>
366
+ </div>
367
+ {commitError !== null && <div className={css.gitError}>{commitError}</div>}
368
+
369
+ <div className={css.gitSection}>
370
+ <div className={css.gitSectionHeader}><span>{t('history')}</span></div>
371
+ {logEntries.map(entry => (
372
+ <div
373
+ key={entry.hashFull}
374
+ role="button"
375
+ tabIndex={0}
376
+ className={css.gitLogRow}
377
+ title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
378
+ onClick={() => { openCommitDiff(entry) }}
379
+ onKeyDown={(event) => {
380
+ if (event.key === 'Enter' || event.key === ' ') {
381
+ event.preventDefault()
382
+ openCommitDiff(entry)
383
+ }
384
+ }}
385
+ onContextMenu={(event) => { openHistoryMenu(event, entry) }}
386
+ >
387
+ <span className={css.gitLogLine1}>
388
+ <span className={css.gitLogHash}>{entry.hash}</span>
389
+ <span className={css.gitLogSubject}>{entry.subject}</span>
390
+ </span>
391
+ <span className={css.gitLogLine2}>
392
+ {refNames(entry.refs).map(ref => (
393
+ <span key={ref} className={css.gitLogRef}>{ref}</span>
394
+ ))}
395
+ <span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
396
+ </span>
397
+ </div>
398
+ ))}
399
+ {!logEnded && (
400
+ <button
401
+ type="button"
402
+ className={css.gitLogMore}
403
+ disabled={logLoadingMore || busy}
404
+ onClick={() => { void loadMoreLog() }}
405
+ >
406
+ {logLoadingMore ? t('loading') : t('loadMore')}
407
+ </button>
408
+ )}
409
+ </div>
410
+
411
+ {/*
412
+ The one shared file-row context menu, positioned at the right-click
413
+ cursor (portal so the panel's overflow clip cannot crop it).
414
+ */}
415
+ <Menu
416
+ open={fileMenu !== null}
417
+ onClose={() => { setFileMenu(null) }}
418
+ items={[
419
+ { id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> },
420
+ ...(writable
421
+ ? [
422
+ fileMenu?.staged === true
423
+ ? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
424
+ : { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
425
+ ...(fileMenu !== null && !isUntracked(fileMenu.entry)
426
+ ? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
427
+ : []),
428
+ ]
429
+ : []),
430
+ { type: 'separator', id: 'sep1' },
431
+ { id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
432
+ { id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
433
+ ]}
434
+ onSelect={(id) => {
435
+ const target = fileMenu
436
+ if (target === null) return
437
+ setFileMenu(null)
438
+ if (id === 'open') {
439
+ onOpenFile(target.entry.path)
440
+ return
441
+ }
442
+ if (id === 'stage') {
443
+ void stageEntry(target.entry, target.staged)
444
+ return
445
+ }
446
+ if (id === 'discard') {
447
+ runConfirmed({
448
+ title: t('discardTitle'),
449
+ description: t('discardDesc', { path: target.entry.path }),
450
+ confirmLabel: t('discard'),
451
+ onConfirm: () => api.gitDiscard(scope, target.entry.path),
452
+ })
453
+ return
454
+ }
455
+ if (id === 'relative') {
456
+ copy(relativeTo(scope.cwd ?? '', target.entry.path))
457
+ return
458
+ }
459
+ if (id === 'absolute') copy(target.entry.path)
460
+ }}
461
+ portal
462
+ align="start"
463
+ getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
464
+ anchor={<span />}
465
+ />
466
+
467
+ {/* The shared history-row context menu. */}
468
+ <Menu
469
+ open={historyMenu !== null}
470
+ onClose={() => { setHistoryMenu(null) }}
471
+ items={[
472
+ { id: 'view', label: t('viewCommitDiff') },
473
+ { id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
474
+ { id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
475
+ { id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
476
+ ...(writable
477
+ ? [
478
+ { type: 'separator' as const, id: 'sep2' },
479
+ { id: 'revert', label: t('revertCommit'), danger: true },
480
+ { id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
481
+ ]
482
+ : []),
483
+ ]}
484
+ onSelect={(id) => {
485
+ const target = historyMenu
486
+ if (target === null) return
487
+ setHistoryMenu(null)
488
+ if (id === 'view') {
489
+ openCommitDiff(target.entry)
490
+ return
491
+ }
492
+ if (id === 'copyShort') {
493
+ copy(target.entry.hash)
494
+ return
495
+ }
496
+ if (id === 'copyFull') {
497
+ copy(target.entry.hashFull)
498
+ return
499
+ }
500
+ if (id === 'copySubject') {
501
+ copy(target.entry.subject)
502
+ return
503
+ }
504
+ if (id === 'revert') {
505
+ runConfirmed({
506
+ title: t('revertTitle'),
507
+ description: t('revertDesc', { subject: target.entry.subject }),
508
+ confirmLabel: t('revertCommit'),
509
+ onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
510
+ })
511
+ return
512
+ }
513
+ if (id === 'cherryPick') {
514
+ runConfirmed({
515
+ title: t('cherryPickTitle'),
516
+ description: t('cherryPickDesc', { subject: target.entry.subject }),
517
+ confirmLabel: t('cherryPickCommit'),
518
+ onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
519
+ })
520
+ }
521
+ }}
522
+ portal
523
+ align="start"
524
+ getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
525
+ anchor={<span />}
526
+ />
527
+
528
+ {/* Destructive actions land here first: Cancel / Confirm. */}
529
+ <Modal
530
+ open={confirm !== null}
531
+ onClose={() => { setConfirm(null) }}
532
+ title={confirm?.title ?? ''}
533
+ closeLabel={t('cancel')}
534
+ footer={(
535
+ <>
536
+ <Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
537
+ <Button
538
+ variant="primary"
539
+ disabled={busy}
540
+ onClick={() => {
541
+ const pending = confirm
542
+ if (pending === null) return
543
+ setConfirm(null)
544
+ void pending.onConfirm()
545
+ }}
546
+ >
547
+ {confirm?.confirmLabel ?? ''}
548
+ </Button>
549
+ </>
550
+ )}
551
+ >
552
+ <p className={css.gitConfirmDesc}>{confirm?.description}</p>
553
+ </Modal>
554
+ </>
555
+ )}
556
+ </div>
557
+ )
558
+ }
@@ -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
+ }