@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.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- 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
|
+
}
|