dsh-coding-sidebar 1.0.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.
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +48229 -0
- package/lib/client-mermaid.js +200745 -0
- package/lib/client-registry.js +21867 -0
- package/lib/client-terminal.js +9361 -0
- package/lib/client.js +21867 -0
- package/lib/index.js +4556 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -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 +39 -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 +13 -0
- package/lib/types/client/FileTree.d.ts +38 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +10 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -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 +54 -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 +24 -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 +275 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -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 +131 -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/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +59 -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 +25 -0
- package/lib/types/client/intercept.d.ts +46 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -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-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +452 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +38 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -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 +85 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -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 +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +439 -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 +36 -0
- package/lib/types/client/state.d.ts +432 -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 +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -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 +605 -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 +124 -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/path-security.d.ts +20 -0
- package/lib/types/prefs-shared.d.ts +236 -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 +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +172 -0
- package/scripts/install.ps1 +306 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -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 +289 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +558 -0
- package/src/client/FileTree.tsx +669 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +717 -0
- package/src/client/MarkdownHtml.tsx +296 -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 +1163 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +645 -0
- package/src/client/Sidebar.tsx +1370 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +275 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +357 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +340 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +358 -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/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +152 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +427 -0
- package/src/client/intercept.tsx +188 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +24 -0
- package/src/client/layout.css +90 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +368 -0
- package/src/client/locales-de.ts +353 -0
- package/src/client/locales-fr.ts +360 -0
- package/src/client/locales-hi.ts +367 -0
- package/src/client/locales-id.ts +365 -0
- package/src/client/locales-it.ts +358 -0
- package/src/client/locales-ja.ts +367 -0
- package/src/client/locales-ko.ts +359 -0
- package/src/client/locales-nl.ts +365 -0
- package/src/client/locales-pl.ts +369 -0
- package/src/client/locales-pt.ts +350 -0
- package/src/client/locales-ru.ts +365 -0
- package/src/client/locales-sv.ts +350 -0
- package/src/client/locales-th.ts +367 -0
- package/src/client/locales-tr.ts +367 -0
- package/src/client/locales-vi.ts +367 -0
- package/src/client/locales-zh-HK.ts +382 -0
- package/src/client/locales-zh-MO.ts +382 -0
- package/src/client/locales-zh-TW.ts +382 -0
- package/src/client/locales.ts +894 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +52 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -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 +143 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +183 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +96 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +210 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +859 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2788 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +331 -0
- package/src/client/state.ts +1497 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -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 +154 -0
- package/src/context-types.ts +611 -0
- package/src/fs-operations.ts +95 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1291 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +75 -0
- package/src/prefs-shared.ts +280 -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 +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,717 @@
|
|
|
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. While visible it polls lightweight porcelain state so model-authored
|
|
10
|
+
* file changes appear without a manual refresh.
|
|
11
|
+
*/
|
|
12
|
+
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
|
13
|
+
import {
|
|
14
|
+
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
|
|
15
|
+
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
|
|
16
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
17
|
+
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from './api.ts'
|
|
18
|
+
import { api } from './api.ts'
|
|
19
|
+
import { isWithinWorkspace, relativeTo } from './paths.ts'
|
|
20
|
+
import { resolveSidebarPath } from './produced-files.ts'
|
|
21
|
+
import { relativeTime, t } from './locales.ts'
|
|
22
|
+
import type { SidebarTab } from './state.ts'
|
|
23
|
+
import css from './sidebar.module.css'
|
|
24
|
+
|
|
25
|
+
/** The XY status letters a row badge shows (X = index, Y = worktree). */
|
|
26
|
+
function badgeOf(entry: GitStatusEntry): string {
|
|
27
|
+
const index = entry.xy[0]
|
|
28
|
+
const worktree = entry.xy[1]
|
|
29
|
+
if (index !== undefined && index !== ' ' && index !== '?') return index
|
|
30
|
+
if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
|
|
31
|
+
return '?'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether the entry carries STAGED (index) changes — the X letter is set. */
|
|
35
|
+
function isStagedEntry(entry: GitStatusEntry): boolean {
|
|
36
|
+
const index = entry.xy[0]
|
|
37
|
+
return index !== undefined && index !== ' ' && index !== '?'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
|
|
41
|
+
* (untracked `??` counts as unstaged: it is a worktree-only change). A file
|
|
42
|
+
* with both letters set ('MM') lands in BOTH sections. */
|
|
43
|
+
function isUnstagedEntry(entry: GitStatusEntry): boolean {
|
|
44
|
+
if (entry.xy === '??') return true
|
|
45
|
+
const worktree = entry.xy[1]
|
|
46
|
+
return worktree !== undefined && worktree !== ' ' && worktree !== '?'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the entry is untracked (`??`): git diff never includes it. */
|
|
50
|
+
function isUntracked(entry: GitStatusEntry): boolean {
|
|
51
|
+
return badgeOf(entry) === '?'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The last path segment (tab title for a file's diff). */
|
|
55
|
+
function baseName(path: string): string {
|
|
56
|
+
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
57
|
+
return at === -1 ? path : path.slice(at + 1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
|
|
61
|
+
function refNames(refs: string): string[] {
|
|
62
|
+
return [...new Set(
|
|
63
|
+
refs
|
|
64
|
+
.split(',')
|
|
65
|
+
.map(ref => ref.trim())
|
|
66
|
+
.filter(ref => ref !== '')
|
|
67
|
+
.map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
|
|
68
|
+
.map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
|
|
69
|
+
)]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
|
|
73
|
+
interface ConfirmState {
|
|
74
|
+
title: string
|
|
75
|
+
description: string
|
|
76
|
+
confirmLabel: string
|
|
77
|
+
onConfirm: () => Promise<unknown>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** History batch size: the log loads lazily in pages so a long history never
|
|
81
|
+
* floods the panel at once (the end of the log is reached by paging). */
|
|
82
|
+
const LOG_BATCH = 20
|
|
83
|
+
|
|
84
|
+
export function GitView(props: {
|
|
85
|
+
scope: SessionScope
|
|
86
|
+
onOpenFile: (path: string) => void
|
|
87
|
+
/** Open a diff tab (the shell places it below the git pane on first use). */
|
|
88
|
+
onOpenDiff: (tab: SidebarTab) => void
|
|
89
|
+
/** Poll only while the tab is actually visible. */
|
|
90
|
+
visible: boolean
|
|
91
|
+
}) {
|
|
92
|
+
const { scope, onOpenFile, onOpenDiff, visible } = props
|
|
93
|
+
const [status, setStatus] = useState<GitStatusResult | null>(null)
|
|
94
|
+
const [worktrees, setWorktrees] = useState<GitWorktree[]>([])
|
|
95
|
+
const [selectedWorktree, setSelectedWorktree] = useState<string | undefined>()
|
|
96
|
+
const [repoRoot, setRepoRoot] = useState<string | undefined>(undefined)
|
|
97
|
+
const [loading, setLoading] = useState(true)
|
|
98
|
+
const [error, setError] = useState<string | null>(null)
|
|
99
|
+
const [branchNames, setBranchNames] = useState<string[]>([])
|
|
100
|
+
const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
|
|
101
|
+
const [commitMsg, setCommitMsg] = useState('')
|
|
102
|
+
const [busy, setBusy] = useState(false)
|
|
103
|
+
const [commitError, setCommitError] = useState<string | null>(null)
|
|
104
|
+
/** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
|
|
105
|
+
const [logEnded, setLogEnded] = useState(false)
|
|
106
|
+
const [logLoadingMore, setLogLoadingMore] = useState(false)
|
|
107
|
+
|
|
108
|
+
/** The open file-row context menu (cursor position for the portaled Menu). */
|
|
109
|
+
const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
|
|
110
|
+
/** The open history-row context menu. */
|
|
111
|
+
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
|
|
112
|
+
/** The pending destructive action awaiting confirmation. */
|
|
113
|
+
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
|
114
|
+
const refreshInFlight = useRef(false)
|
|
115
|
+
/** Monotonic request id: a manual worktree switch invalidates any older poll
|
|
116
|
+
* before it can publish state from the previous checkout. */
|
|
117
|
+
const refreshGeneration = useRef(0)
|
|
118
|
+
const worktreeChosenByUser = useRef(false)
|
|
119
|
+
/** selectedWorktree read inside refresh without re-creating the callback:
|
|
120
|
+
* avoids a spurious full refresh on every auto-select (the very state
|
|
121
|
+
* change refresh writes back via setSelectedWorktree would recreate the
|
|
122
|
+
* callback and re-trigger the mount effect — an N→N+1 fetch loop). */
|
|
123
|
+
const selectedRef = useRef<string | undefined>(undefined)
|
|
124
|
+
useEffect(() => { selectedRef.current = selectedWorktree }, [selectedWorktree])
|
|
125
|
+
|
|
126
|
+
const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
|
|
127
|
+
|
|
128
|
+
/** Publish a complete checkout-derived view. Status, branch choices and
|
|
129
|
+
* history are one consistency unit: never mix rows from two worktrees. */
|
|
130
|
+
const refreshTarget = useCallback(async (
|
|
131
|
+
target: string | undefined,
|
|
132
|
+
options: { loading: boolean; generation: number },
|
|
133
|
+
): Promise<void> => {
|
|
134
|
+
if (options.loading) setLoading(true)
|
|
135
|
+
setError(null)
|
|
136
|
+
try {
|
|
137
|
+
const [statusResult, branchResult, logResult] = await Promise.all([
|
|
138
|
+
api.gitStatus(gitScope, target),
|
|
139
|
+
api.gitBranch(gitScope, target).catch(() => ({ current: '', names: [] as string[] })),
|
|
140
|
+
api.gitLog(gitScope, LOG_BATCH, 0, target).catch(() => [] as GitLogEntry[]),
|
|
141
|
+
])
|
|
142
|
+
if (options.generation !== refreshGeneration.current) return
|
|
143
|
+
setStatus(statusResult)
|
|
144
|
+
if (statusResult.root !== undefined && statusResult.root !== repoRoot) setRepoRoot(statusResult.root)
|
|
145
|
+
setBranchNames(branchResult.names)
|
|
146
|
+
setLogEntries(logResult)
|
|
147
|
+
setLogEnded(logResult.length < LOG_BATCH)
|
|
148
|
+
} catch (reason) {
|
|
149
|
+
if (options.generation === refreshGeneration.current) {
|
|
150
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
|
|
154
|
+
}
|
|
155
|
+
}, [scope.sessionId, scope.cwd, repoRoot])
|
|
156
|
+
|
|
157
|
+
const refresh = useCallback(async (silent = false): Promise<void> => {
|
|
158
|
+
if (refreshInFlight.current) return
|
|
159
|
+
refreshInFlight.current = true
|
|
160
|
+
let generation = refreshGeneration.current
|
|
161
|
+
try {
|
|
162
|
+
const listed = await api.gitWorktrees(scope)
|
|
163
|
+
if (generation !== refreshGeneration.current) return
|
|
164
|
+
setWorktrees(listed)
|
|
165
|
+
const selectedStillExists = listed.some(entry => entry.path === selectedRef.current)
|
|
166
|
+
let target = selectedStillExists ? selectedRef.current : listed.find(entry => entry.current)?.path
|
|
167
|
+
// DSH and other coding agents commonly create one linked checkout while
|
|
168
|
+
// the session remains rooted at the clean primary checkout. Select that
|
|
169
|
+
// checkout automatically only when the choice is unambiguous.
|
|
170
|
+
const current = listed.find(entry => entry.current)
|
|
171
|
+
const dirtyLinked = listed.filter(entry => !entry.current && entry.changes > 0)
|
|
172
|
+
if (!worktreeChosenByUser.current) {
|
|
173
|
+
target = (current?.changes ?? 0) === 0 && dirtyLinked.length === 1
|
|
174
|
+
? dirtyLinked[0]!.path
|
|
175
|
+
: current?.path
|
|
176
|
+
}
|
|
177
|
+
const targetChanged = target !== selectedRef.current
|
|
178
|
+
if (targetChanged) {
|
|
179
|
+
// Changing the automatically selected checkout invalidates any direct
|
|
180
|
+
// target refresh that may still be resolving for the previous one.
|
|
181
|
+
generation = refreshGeneration.current += 1
|
|
182
|
+
selectedRef.current = target
|
|
183
|
+
setSelectedWorktree(target)
|
|
184
|
+
// Remove rows owned by the previous checkout immediately: keeping them
|
|
185
|
+
// interactive while the target changes could apply a destructive action
|
|
186
|
+
// to the new checkout with stale history from the old one.
|
|
187
|
+
setStatus(null)
|
|
188
|
+
setBranchNames([])
|
|
189
|
+
setLogEntries([])
|
|
190
|
+
setLogEnded(false)
|
|
191
|
+
setLogLoadingMore(false)
|
|
192
|
+
}
|
|
193
|
+
// A poll may update status alone only while staying on the same checkout.
|
|
194
|
+
// Any automatic selection change refreshes the complete derived view.
|
|
195
|
+
if (silent && !targetChanged) {
|
|
196
|
+
const statusResult = await api.gitStatus(gitScope, target)
|
|
197
|
+
if (generation === refreshGeneration.current) setStatus(statusResult)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
await refreshTarget(target, { loading: !silent, generation })
|
|
201
|
+
} catch (reason) {
|
|
202
|
+
if (generation === refreshGeneration.current) {
|
|
203
|
+
setError(reason instanceof Error ? reason.message : String(reason))
|
|
204
|
+
if (!silent) setLoading(false)
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
refreshInFlight.current = false
|
|
208
|
+
}
|
|
209
|
+
}, [scope.sessionId, scope.cwd, refreshTarget])
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
refreshGeneration.current += 1
|
|
213
|
+
refreshInFlight.current = false
|
|
214
|
+
worktreeChosenByUser.current = false
|
|
215
|
+
selectedRef.current = undefined
|
|
216
|
+
setSelectedWorktree(undefined)
|
|
217
|
+
}, [scope.sessionId, scope.cwd])
|
|
218
|
+
useEffect(() => { void refresh() }, [refresh])
|
|
219
|
+
|
|
220
|
+
/** A user choice invalidates any older poll and atomically refreshes every
|
|
221
|
+
* checkout-derived surface before destructive history actions can run. */
|
|
222
|
+
const chooseWorktree = (target: string): void => {
|
|
223
|
+
worktreeChosenByUser.current = true
|
|
224
|
+
selectedRef.current = target
|
|
225
|
+
setSelectedWorktree(target)
|
|
226
|
+
setStatus(null)
|
|
227
|
+
setBranchNames([])
|
|
228
|
+
setLogEntries([])
|
|
229
|
+
setLogEnded(false)
|
|
230
|
+
setLogLoadingMore(false)
|
|
231
|
+
const generation = refreshGeneration.current += 1
|
|
232
|
+
void refreshTarget(target, { loading: true, generation })
|
|
233
|
+
}
|
|
234
|
+
/** Switching the selected child repository must invalidate every
|
|
235
|
+
* target-derived surface (status/history/log) before the asynchronous
|
|
236
|
+
* refresh resolves; otherwise stale rows remain actionable while their
|
|
237
|
+
* handlers already address the new repository. Mirrors chooseWorktree. */
|
|
238
|
+
const chooseRepo = (target: string): void => {
|
|
239
|
+
setRepoRoot(target)
|
|
240
|
+
setStatus(null)
|
|
241
|
+
setBranchNames([])
|
|
242
|
+
setLogEntries([])
|
|
243
|
+
setLogEnded(false)
|
|
244
|
+
setLogLoadingMore(false)
|
|
245
|
+
// Re-list worktrees for the selected child (a workspace container's
|
|
246
|
+
// own worktree list is empty); keep the current linked-checkout choice
|
|
247
|
+
// unless it does not belong to the new repository.
|
|
248
|
+
const generation = refreshGeneration.current += 1
|
|
249
|
+
void refreshTarget(selectedRef.current ?? '', { loading: true, generation })
|
|
250
|
+
}
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (!visible) return
|
|
253
|
+
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
|
|
254
|
+
return () => { window.clearInterval(timer) }
|
|
255
|
+
}, [visible, refresh])
|
|
256
|
+
|
|
257
|
+
/** Append the next history page (lazy: only when the user asks for more). */
|
|
258
|
+
const loadMoreLog = async (): Promise<void> => {
|
|
259
|
+
if (logLoadingMore || logEnded) return
|
|
260
|
+
const generation = refreshGeneration.current
|
|
261
|
+
const target = selectedRef.current
|
|
262
|
+
setLogLoadingMore(true)
|
|
263
|
+
try {
|
|
264
|
+
const next = await api.gitLog(gitScope, LOG_BATCH, logEntries.length, target)
|
|
265
|
+
// A worktree switch clears the old history and increments generation.
|
|
266
|
+
// Never append a late page from that checkout into the new one.
|
|
267
|
+
if (generation !== refreshGeneration.current || target !== selectedRef.current) return
|
|
268
|
+
setLogEntries(entries => [...entries, ...next])
|
|
269
|
+
if (next.length < LOG_BATCH) setLogEnded(true)
|
|
270
|
+
} catch (reason) {
|
|
271
|
+
if (generation === refreshGeneration.current && target === selectedRef.current) {
|
|
272
|
+
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
if (generation === refreshGeneration.current && target === selectedRef.current) setLogLoadingMore(false)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
|
|
280
|
+
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
|
|
281
|
+
onOpenDiff({
|
|
282
|
+
id: `diff:w:${encodeURIComponent(selectedWorktree ?? '')}:${staged ? 's' : 'u'}:${entry.path}`,
|
|
283
|
+
type: 'diff',
|
|
284
|
+
title: baseName(entry.path),
|
|
285
|
+
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree, repoRoot },
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The diff tab for one commit (one tab per commit). */
|
|
290
|
+
const openCommitDiff = (entry: GitLogEntry): void => {
|
|
291
|
+
onOpenDiff({
|
|
292
|
+
id: `diff:c:${encodeURIComponent(selectedWorktree ?? '')}:${entry.hashFull}`,
|
|
293
|
+
type: 'diff',
|
|
294
|
+
title: `${entry.hash} ${entry.subject}`,
|
|
295
|
+
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject, worktree: selectedWorktree, repoRoot },
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
|
|
300
|
+
setBusy(true)
|
|
301
|
+
try {
|
|
302
|
+
if (staged) await api.gitUnstage(gitScope, entry.path, selectedWorktree)
|
|
303
|
+
else await api.gitStage(gitScope, entry.path, selectedWorktree)
|
|
304
|
+
await refresh()
|
|
305
|
+
} finally {
|
|
306
|
+
setBusy(false)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const stageAll = async (staged: boolean): Promise<void> => {
|
|
311
|
+
setBusy(true)
|
|
312
|
+
try {
|
|
313
|
+
if (staged) await api.gitUnstage(gitScope, undefined, selectedWorktree)
|
|
314
|
+
else await api.gitStage(gitScope, undefined, selectedWorktree)
|
|
315
|
+
await refresh()
|
|
316
|
+
} finally {
|
|
317
|
+
setBusy(false)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const commit = async (): Promise<void> => {
|
|
322
|
+
const message = commitMsg.trim()
|
|
323
|
+
if (message === '' || busy) return
|
|
324
|
+
setBusy(true)
|
|
325
|
+
setCommitError(null)
|
|
326
|
+
try {
|
|
327
|
+
await api.gitCommit(gitScope, message, selectedWorktree)
|
|
328
|
+
setCommitMsg('')
|
|
329
|
+
await refresh()
|
|
330
|
+
} catch (reason) {
|
|
331
|
+
setCommitError(reason instanceof Error ? reason.message : String(reason))
|
|
332
|
+
} finally {
|
|
333
|
+
setBusy(false)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const checkout = async (branch: string): Promise<void> => {
|
|
338
|
+
if (branch === status?.branch || busy) return
|
|
339
|
+
setBusy(true)
|
|
340
|
+
setCommitError(null)
|
|
341
|
+
try {
|
|
342
|
+
await api.gitCheckout(gitScope, branch, selectedWorktree)
|
|
343
|
+
await refresh()
|
|
344
|
+
} catch (reason) {
|
|
345
|
+
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
|
|
346
|
+
} finally {
|
|
347
|
+
setBusy(false)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Run one destructive operation after the confirm modal, then refresh. */
|
|
352
|
+
const runConfirmed = (confirmState: ConfirmState): void => {
|
|
353
|
+
setConfirm({ ...confirmState, onConfirm: async () => {
|
|
354
|
+
setBusy(true)
|
|
355
|
+
setCommitError(null)
|
|
356
|
+
try {
|
|
357
|
+
await confirmState.onConfirm()
|
|
358
|
+
await refresh()
|
|
359
|
+
} catch (reason) {
|
|
360
|
+
setCommitError(reason instanceof Error ? reason.message : String(reason))
|
|
361
|
+
} finally {
|
|
362
|
+
setBusy(false)
|
|
363
|
+
}
|
|
364
|
+
} })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
|
|
368
|
+
const copy = (text: string): void => {
|
|
369
|
+
void writeClipboard(text)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
|
|
373
|
+
event.preventDefault()
|
|
374
|
+
event.stopPropagation()
|
|
375
|
+
setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
|
|
379
|
+
event.preventDefault()
|
|
380
|
+
event.stopPropagation()
|
|
381
|
+
setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
|
|
385
|
+
const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
|
|
386
|
+
|
|
387
|
+
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
|
|
388
|
+
return (
|
|
389
|
+
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
className={css.gitRowMain}
|
|
393
|
+
title={entry.path}
|
|
394
|
+
onClick={() => { openWorktreeDiff(entry, staged) }}
|
|
395
|
+
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
|
|
396
|
+
>
|
|
397
|
+
<span className={css.gitBadge}>{badgeOf(entry)}</span>
|
|
398
|
+
<span className={css.gitName}>{entry.path}</span>
|
|
399
|
+
</button>
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
className={css.iconButton}
|
|
403
|
+
aria-label={staged ? t('unstage') : t('stage')}
|
|
404
|
+
title={staged ? t('unstage') : t('stage')}
|
|
405
|
+
disabled={busy}
|
|
406
|
+
onClick={() => { void stageEntry(entry, staged) }}
|
|
407
|
+
>
|
|
408
|
+
{staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
|
|
409
|
+
</button>
|
|
410
|
+
</div>
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<div className={css.git}>
|
|
416
|
+
{worktrees.length > 1 && (
|
|
417
|
+
<div className={css.gitWorktreeRow}>
|
|
418
|
+
<span className={css.gitWorktreeLabel}>{t('worktree')}</span>
|
|
419
|
+
<select
|
|
420
|
+
className={css.gitBranchSelect}
|
|
421
|
+
value={selectedWorktree ?? ''}
|
|
422
|
+
title={selectedWorktree}
|
|
423
|
+
disabled={busy}
|
|
424
|
+
onChange={(event) => { chooseWorktree(event.target.value) }}
|
|
425
|
+
>
|
|
426
|
+
{worktrees.map(entry => (
|
|
427
|
+
<option key={entry.path} value={entry.path}>
|
|
428
|
+
{entry.branch} · {baseName(entry.path)} ({entry.changes})
|
|
429
|
+
</option>
|
|
430
|
+
))}
|
|
431
|
+
</select>
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
<div className={css.gitHeader}>
|
|
435
|
+
{(status?.repositories?.length ?? 0) > 1 && (
|
|
436
|
+
<select
|
|
437
|
+
className={css.gitBranchSelect}
|
|
438
|
+
value={repoRoot ?? ''}
|
|
439
|
+
title={repoRoot}
|
|
440
|
+
onChange={(event) => { chooseRepo(event.target.value) }}
|
|
441
|
+
disabled={busy}
|
|
442
|
+
>
|
|
443
|
+
{status!.repositories!.map(root => <option key={root} value={root}>{baseName(root)}</option>)}
|
|
444
|
+
</select>
|
|
445
|
+
)}
|
|
446
|
+
<select
|
|
447
|
+
className={css.gitBranchSelect}
|
|
448
|
+
value={status?.branch ?? ''}
|
|
449
|
+
onChange={(event) => { void checkout(event.target.value) }}
|
|
450
|
+
disabled={busy || (status !== null && !status.isRepo)}
|
|
451
|
+
>
|
|
452
|
+
{(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
|
|
453
|
+
{branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
|
|
454
|
+
</select>
|
|
455
|
+
<button
|
|
456
|
+
type="button"
|
|
457
|
+
className={css.iconButton}
|
|
458
|
+
aria-label={t('refresh')}
|
|
459
|
+
title={t('refresh')}
|
|
460
|
+
onClick={() => { void refresh() }}
|
|
461
|
+
>
|
|
462
|
+
<IconRefreshOutline16 size={14} />
|
|
463
|
+
</button>
|
|
464
|
+
</div>
|
|
465
|
+
|
|
466
|
+
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
|
467
|
+
{!loading && error !== null && <div className={css.gitError}>{error}</div>}
|
|
468
|
+
{!loading && status !== null && !status.isRepo && (
|
|
469
|
+
<div className={css.gitPlaceholder}>{t('notRepo')}</div>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{status !== null && status.isRepo && (
|
|
473
|
+
<>
|
|
474
|
+
{status.truncated === true && (
|
|
475
|
+
<div className={css.gitEmpty}>{t('statusTruncated')}</div>
|
|
476
|
+
)}
|
|
477
|
+
<div className={css.gitSection}>
|
|
478
|
+
<div className={css.gitSectionHeader}>
|
|
479
|
+
<span>{t('staged')} ({stagedEntries.length})</span>
|
|
480
|
+
{stagedEntries.length > 0 && (
|
|
481
|
+
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
|
|
482
|
+
{t('unstageAll')}
|
|
483
|
+
</button>
|
|
484
|
+
)}
|
|
485
|
+
</div>
|
|
486
|
+
{stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
|
|
487
|
+
{stagedEntries.map(entry => renderEntry(entry, true))}
|
|
488
|
+
</div>
|
|
489
|
+
<div className={css.gitSection}>
|
|
490
|
+
<div className={css.gitSectionHeader}>
|
|
491
|
+
<span>{t('unstaged')} ({unstagedEntries.length})</span>
|
|
492
|
+
{unstagedEntries.length > 0 && (
|
|
493
|
+
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
|
|
494
|
+
{t('stageAll')}
|
|
495
|
+
</button>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
{unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
|
|
499
|
+
{unstagedEntries.map(entry => renderEntry(entry, false))}
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<div className={css.gitCommit}>
|
|
503
|
+
<Input
|
|
504
|
+
className={css.gitCommitInput}
|
|
505
|
+
placeholder={t('commitPlaceholder')}
|
|
506
|
+
value={commitMsg}
|
|
507
|
+
disabled={busy}
|
|
508
|
+
onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
|
|
509
|
+
onKeyDown={(event) => {
|
|
510
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
|
|
511
|
+
}}
|
|
512
|
+
/>
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
className={css.gitCommitButton}
|
|
516
|
+
disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
|
|
517
|
+
onClick={() => { void commit() }}
|
|
518
|
+
>
|
|
519
|
+
{t('commit')}
|
|
520
|
+
</button>
|
|
521
|
+
</div>
|
|
522
|
+
{commitError !== null && <div className={css.gitError}>{commitError}</div>}
|
|
523
|
+
|
|
524
|
+
<div className={css.gitSection}>
|
|
525
|
+
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
|
|
526
|
+
{logEntries.map(entry => (
|
|
527
|
+
<div
|
|
528
|
+
key={entry.hashFull}
|
|
529
|
+
role="button"
|
|
530
|
+
tabIndex={0}
|
|
531
|
+
className={css.gitLogRow}
|
|
532
|
+
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
|
|
533
|
+
onClick={() => { openCommitDiff(entry) }}
|
|
534
|
+
onKeyDown={(event) => {
|
|
535
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
536
|
+
event.preventDefault()
|
|
537
|
+
openCommitDiff(entry)
|
|
538
|
+
}
|
|
539
|
+
}}
|
|
540
|
+
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
|
|
541
|
+
>
|
|
542
|
+
<span className={css.gitLogLine1}>
|
|
543
|
+
<span className={css.gitLogHash}>{entry.hash}</span>
|
|
544
|
+
<span className={css.gitLogSubject}>{entry.subject}</span>
|
|
545
|
+
</span>
|
|
546
|
+
<span className={css.gitLogLine2}>
|
|
547
|
+
{refNames(entry.refs).map(ref => (
|
|
548
|
+
<span key={ref} className={css.gitLogRef}>{ref}</span>
|
|
549
|
+
))}
|
|
550
|
+
<span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
|
|
551
|
+
</span>
|
|
552
|
+
</div>
|
|
553
|
+
))}
|
|
554
|
+
{!logEnded && (
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
className={css.gitLogMore}
|
|
558
|
+
disabled={logLoadingMore || busy}
|
|
559
|
+
onClick={() => { void loadMoreLog() }}
|
|
560
|
+
>
|
|
561
|
+
{logLoadingMore ? t('loading') : t('loadMore')}
|
|
562
|
+
</button>
|
|
563
|
+
)}
|
|
564
|
+
</div>
|
|
565
|
+
|
|
566
|
+
{/*
|
|
567
|
+
The one shared file-row context menu, positioned at the right-click
|
|
568
|
+
cursor (portal so the panel's overflow clip cannot crop it).
|
|
569
|
+
*/}
|
|
570
|
+
<Menu
|
|
571
|
+
open={fileMenu !== null}
|
|
572
|
+
onClose={() => { setFileMenu(null) }}
|
|
573
|
+
items={[
|
|
574
|
+
// A linked worktree outside the session workspace cannot be
|
|
575
|
+
// opened in the editor: the host's workspace fence rejects
|
|
576
|
+
// every path under it. Hide the action for that checkout so
|
|
577
|
+
// the menu does not offer a no-op that confuses the user.
|
|
578
|
+
...(fileMenu !== null && isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path))
|
|
579
|
+
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> }]
|
|
580
|
+
: []),
|
|
581
|
+
fileMenu?.staged === true
|
|
582
|
+
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
|
|
583
|
+
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
|
|
584
|
+
...(fileMenu !== null && !isUntracked(fileMenu.entry)
|
|
585
|
+
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
|
|
586
|
+
: []),
|
|
587
|
+
{ type: 'separator', id: 'sep1' },
|
|
588
|
+
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
|
589
|
+
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
|
590
|
+
]}
|
|
591
|
+
onSelect={(id) => {
|
|
592
|
+
const target = fileMenu
|
|
593
|
+
if (target === null) return
|
|
594
|
+
setFileMenu(null)
|
|
595
|
+
if (id === 'open') {
|
|
596
|
+
const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path)
|
|
597
|
+
// Defense-in-depth: the menu hides this action when the
|
|
598
|
+
// resolved path escapes the session workspace, but a
|
|
599
|
+
// racing repo switch could still reach here with a path
|
|
600
|
+
// the host would reject. No-op in that case.
|
|
601
|
+
if (!isWithinWorkspace(scope.cwd ?? '', resolved)) return
|
|
602
|
+
onOpenFile(resolved)
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
if (id === 'stage') {
|
|
606
|
+
void stageEntry(target.entry, target.staged)
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
if (id === 'discard') {
|
|
610
|
+
runConfirmed({
|
|
611
|
+
title: t('discardTitle'),
|
|
612
|
+
description: t('discardDesc', { path: target.entry.path }),
|
|
613
|
+
confirmLabel: t('discard'),
|
|
614
|
+
onConfirm: () => api.gitDiscard(gitScope, target.entry.path, selectedWorktree),
|
|
615
|
+
})
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
if (id === 'relative') {
|
|
619
|
+
copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.entry.path))
|
|
620
|
+
return
|
|
621
|
+
}
|
|
622
|
+
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
|
|
623
|
+
}}
|
|
624
|
+
portal
|
|
625
|
+
align="start"
|
|
626
|
+
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
|
|
627
|
+
anchor={<span />}
|
|
628
|
+
/>
|
|
629
|
+
|
|
630
|
+
{/* The shared history-row context menu. */}
|
|
631
|
+
<Menu
|
|
632
|
+
open={historyMenu !== null}
|
|
633
|
+
onClose={() => { setHistoryMenu(null) }}
|
|
634
|
+
items={[
|
|
635
|
+
{ id: 'view', label: t('viewCommitDiff') },
|
|
636
|
+
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
|
|
637
|
+
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
|
|
638
|
+
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
|
|
639
|
+
{ type: 'separator', id: 'sep2' },
|
|
640
|
+
{ id: 'revert', label: t('revertCommit'), danger: true },
|
|
641
|
+
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
|
|
642
|
+
]}
|
|
643
|
+
onSelect={(id) => {
|
|
644
|
+
const target = historyMenu
|
|
645
|
+
if (target === null) return
|
|
646
|
+
setHistoryMenu(null)
|
|
647
|
+
if (id === 'view') {
|
|
648
|
+
openCommitDiff(target.entry)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
if (id === 'copyShort') {
|
|
652
|
+
copy(target.entry.hash)
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
if (id === 'copyFull') {
|
|
656
|
+
copy(target.entry.hashFull)
|
|
657
|
+
return
|
|
658
|
+
}
|
|
659
|
+
if (id === 'copySubject') {
|
|
660
|
+
copy(target.entry.subject)
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
if (id === 'revert') {
|
|
664
|
+
runConfirmed({
|
|
665
|
+
title: t('revertTitle'),
|
|
666
|
+
description: t('revertDesc', { subject: target.entry.subject }),
|
|
667
|
+
confirmLabel: t('revertCommit'),
|
|
668
|
+
onConfirm: () => api.gitRevert(gitScope, target.entry.hashFull, selectedWorktree),
|
|
669
|
+
})
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
if (id === 'cherryPick') {
|
|
673
|
+
runConfirmed({
|
|
674
|
+
title: t('cherryPickTitle'),
|
|
675
|
+
description: t('cherryPickDesc', { subject: target.entry.subject }),
|
|
676
|
+
confirmLabel: t('cherryPickCommit'),
|
|
677
|
+
onConfirm: () => api.gitCherryPick(gitScope, target.entry.hashFull, selectedWorktree),
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
}}
|
|
681
|
+
portal
|
|
682
|
+
align="start"
|
|
683
|
+
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
|
|
684
|
+
anchor={<span />}
|
|
685
|
+
/>
|
|
686
|
+
|
|
687
|
+
{/* Destructive actions land here first: Cancel / Confirm. */}
|
|
688
|
+
<Modal
|
|
689
|
+
open={confirm !== null}
|
|
690
|
+
onClose={() => { setConfirm(null) }}
|
|
691
|
+
title={confirm?.title ?? ''}
|
|
692
|
+
closeLabel={t('cancel')}
|
|
693
|
+
footer={(
|
|
694
|
+
<>
|
|
695
|
+
<Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
|
|
696
|
+
<Button
|
|
697
|
+
variant="primary"
|
|
698
|
+
disabled={busy}
|
|
699
|
+
onClick={() => {
|
|
700
|
+
const pending = confirm
|
|
701
|
+
if (pending === null) return
|
|
702
|
+
setConfirm(null)
|
|
703
|
+
void pending.onConfirm()
|
|
704
|
+
}}
|
|
705
|
+
>
|
|
706
|
+
{confirm?.confirmLabel ?? ''}
|
|
707
|
+
</Button>
|
|
708
|
+
</>
|
|
709
|
+
)}
|
|
710
|
+
>
|
|
711
|
+
<p className={css.gitConfirmDesc}>{confirm?.description}</p>
|
|
712
|
+
</Modal>
|
|
713
|
+
</>
|
|
714
|
+
)}
|
|
715
|
+
</div>
|
|
716
|
+
)
|
|
717
|
+
}
|