@chatcode/cco-better-sidebar 0.18.0-alpha.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 +554 -0
- package/README_EN.md +563 -0
- package/assets/logo.svg +1 -0
- package/cordis.patch.yml +79 -0
- package/lib/client-editor.js +55744 -0
- package/lib/client-mermaid.js +208256 -0
- package/lib/client-registry.js +30378 -0
- package/lib/client-terminal.js +16894 -0
- package/lib/client.js +30378 -0
- package/lib/index.js +6897 -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/chatcode-update.d.ts +47 -0
- package/lib/types/client/AccountMenu.d.ts +1 -0
- package/lib/types/client/AiRatioSection.d.ts +3 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/ChatcodeGate.d.ts +6 -0
- package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -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/FenceErrorNotice.d.ts +5 -0
- package/lib/types/client/FileTree.d.ts +41 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +12 -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/ReleaseNotesDialog.d.ts +11 -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/Sidebar.d.ts +8 -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 +27 -0
- package/lib/types/client/UpdateDialog.d.ts +10 -0
- package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
- package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +345 -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/chat-presentation.d.ts +13 -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/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +66 -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 +44 -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 +571 -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 +21 -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 +52 -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 +445 -0
- package/lib/types/client/session-pins.d.ts +14 -0
- package/lib/types/client/settings-nav-icon.d.ts +27 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/split-pane.d.ts +38 -0
- package/lib/types/client/state.d.ts +480 -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/ui-tweaks/appicons.d.ts +16 -0
- package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
- package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
- package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
- package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
- package/lib/types/client/ui-tweaks/index.d.ts +279 -0
- package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
- package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
- package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/useChatcodeAccount.d.ts +10 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/codingplan.d.ts +37 -0
- package/lib/types/config.d.ts +95 -0
- package/lib/types/context-types.d.ts +540 -0
- package/lib/types/fs-operations.d.ts +30 -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/login-route.d.ts +2 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +26 -0
- package/lib/types/prefs-shared.d.ts +257 -0
- package/lib/types/pty-deps.d.ts +79 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/ui-tweaks/archive.d.ts +71 -0
- package/lib/types/ui-tweaks/config.d.ts +186 -0
- package/lib/types/ui-tweaks/git-web.d.ts +43 -0
- package/lib/types/ui-tweaks/git.d.ts +311 -0
- package/lib/types/ui-tweaks/index.d.ts +17 -0
- package/lib/types/ui-tweaks/mcp.d.ts +103 -0
- package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
- package/lib/types/ui-tweaks/web.d.ts +55 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +213 -0
- package/scripts/install.ps1 +310 -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/chatcode-update.ts +193 -0
- package/src/client/AccountMenu.tsx +177 -0
- package/src/client/AiRatioSection.module.css +329 -0
- package/src/client/AiRatioSection.tsx +292 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/ChatcodeGate.tsx +35 -0
- package/src/client/CodingPlanModelsSettings.module.css +118 -0
- package/src/client/CodingPlanModelsSettings.tsx +101 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +563 -0
- package/src/client/FenceErrorNotice.tsx +40 -0
- package/src/client/FileTree.tsx +701 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +722 -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/ReleaseNotesDialog.tsx +91 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +932 -0
- package/src/client/SideCardSection.tsx +1169 -0
- package/src/client/Sidebar.tsx +1857 -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 +279 -0
- package/src/client/UpdateDialog.tsx +186 -0
- package/src/client/UpdateSettingsPanel.tsx +29 -0
- package/src/client/UpdateSettingsSection.tsx +22 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/UsageHeatmapSection.module.css +440 -0
- package/src/client/UsageHeatmapSection.tsx +301 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +433 -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 +275 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chat-presentation.ts +95 -0
- package/src/client/chunk-loader.ts +350 -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 +170 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +501 -0
- package/src/client/intercept.tsx +172 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +37 -0
- package/src/client/layout.css +156 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +387 -0
- package/src/client/locales-de.ts +372 -0
- package/src/client/locales-fr.ts +379 -0
- package/src/client/locales-hi.ts +386 -0
- package/src/client/locales-id.ts +384 -0
- package/src/client/locales-it.ts +377 -0
- package/src/client/locales-ja.ts +486 -0
- package/src/client/locales-ko.ts +378 -0
- package/src/client/locales-nl.ts +384 -0
- package/src/client/locales-pl.ts +388 -0
- package/src/client/locales-pt.ts +369 -0
- package/src/client/locales-ru.ts +384 -0
- package/src/client/locales-sv.ts +369 -0
- package/src/client/locales-th.ts +386 -0
- package/src/client/locales-tr.ts +386 -0
- package/src/client/locales-vi.ts +386 -0
- package/src/client/locales-zh-HK.ts +401 -0
- package/src/client/locales-zh-MO.ts +401 -0
- package/src/client/locales-zh-TW.ts +401 -0
- package/src/client/locales.ts +1229 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +31 -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 +79 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +184 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +153 -0
- package/src/client/plugins-viewers.ts +44 -0
- package/src/client/prefs.ts +216 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +873 -0
- package/src/client/session-pins.tsx +334 -0
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +3607 -0
- package/src/client/split-pane.tsx +333 -0
- package/src/client/state.ts +1646 -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/ui-tweaks/appicons.ts +24 -0
- package/src/client/ui-tweaks/archive.tsx +281 -0
- package/src/client/ui-tweaks/branding.tsx +232 -0
- package/src/client/ui-tweaks/cachehit.tsx +122 -0
- package/src/client/ui-tweaks/gitbar.tsx +2559 -0
- package/src/client/ui-tweaks/index.tsx +1696 -0
- package/src/client/ui-tweaks/mcp.tsx +582 -0
- package/src/client/ui-tweaks/notifier.ts +514 -0
- package/src/client/ui-tweaks/whale.tsx +171 -0
- package/src/client/upload.ts +187 -0
- package/src/client/useChatcodeAccount.ts +103 -0
- package/src/client/wco.ts +120 -0
- package/src/codingplan.ts +146 -0
- package/src/config.ts +207 -0
- package/src/context-types.ts +559 -0
- package/src/fs-operations.ts +97 -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 +1524 -0
- package/src/invariant.ts +32 -0
- package/src/login-route.ts +26 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +81 -0
- package/src/prefs-shared.ts +303 -0
- package/src/pty-deps.ts +241 -0
- package/src/pty-manager.ts +376 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/ui-tweaks/archive.ts +293 -0
- package/src/ui-tweaks/config.ts +257 -0
- package/src/ui-tweaks/git-web.ts +421 -0
- package/src/ui-tweaks/git.ts +1524 -0
- package/src/ui-tweaks/index.ts +61 -0
- package/src/ui-tweaks/mcp.ts +535 -0
- package/src/ui-tweaks/turn-outcome.ts +105 -0
- package/src/ui-tweaks/web.ts +225 -0
- package/src/wire.ts +110 -0
- package/vendor/addon-fit.js +2 -0
- package/vendor/xterm.css +218 -0
- package/vendor/xterm.js +5 -0
|
@@ -0,0 +1,2559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ui-tweaks — GitBar (browser half).
|
|
3
|
+
*
|
|
4
|
+
* Two compact DSH-native pills that live INSIDE the composer's tool row
|
|
5
|
+
* (`conversation.input.left` / `.right`), styled to match the input bar's
|
|
6
|
+
* resident chrome (access mode / model select):
|
|
7
|
+
*
|
|
8
|
+
* - **branch** (`conversation.input.left`, right after the access-mode
|
|
9
|
+
* control): current branch; opens a popup with local/remote branches and a
|
|
10
|
+
* new-branch field.
|
|
11
|
+
* - **diff** (`conversation.input.right`, just before the model select): ±line
|
|
12
|
+
* counts; opens a right slide-over panel with the changed-file list and
|
|
13
|
+
* per-file diff (changed hunks by default, whole file on toggle). The panel
|
|
14
|
+
* keeps its commit band at the foot, so committing still works from the diff
|
|
15
|
+
* view.
|
|
16
|
+
*
|
|
17
|
+
* The standalone commit pill (a third pill on its own row above the composer)
|
|
18
|
+
* is gone — the input area no longer carries a separate GitBar row above the
|
|
19
|
+
* card, which is what kept it tall.
|
|
20
|
+
*
|
|
21
|
+
* All colors ride the DSH theme tokens (`--dsw-alias-*`), so light and dark
|
|
22
|
+
* both work. Each pill renders nothing when the setting is off, the session
|
|
23
|
+
* has no cwd, or the directory is not a git repository.
|
|
24
|
+
* @module dsh-ui-tweaks/client/gitbar
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
28
|
+
import { createPortal } from 'react-dom'
|
|
29
|
+
import { createRoot } from 'react-dom/client'
|
|
30
|
+
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
31
|
+
import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
32
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
33
|
+
import type { SettingsClient } from './index.tsx'
|
|
34
|
+
import { APP_ICONS } from './appicons.ts'
|
|
35
|
+
|
|
36
|
+
/** Route prefix matching the host half (src/git-web.ts). */
|
|
37
|
+
const GIT_ROUTE = '/_dsh/ui-tweaks/git'
|
|
38
|
+
|
|
39
|
+
/** Poll interval for the status snapshot, in ms. */
|
|
40
|
+
const POLL_MS = 10000
|
|
41
|
+
|
|
42
|
+
/** Default cap of the file list while its height is still automatic, in px. */
|
|
43
|
+
const FILES_AUTO_MAX = 150
|
|
44
|
+
/** Floors for the two resizable diff-panel sections, in px. */
|
|
45
|
+
const MIN_FILES_H = 30
|
|
46
|
+
const MIN_DIFF_H = 60
|
|
47
|
+
const MIN_COMMIT_H = 62
|
|
48
|
+
/** Share of the panel a dragged section may never exceed, so the diff survives. */
|
|
49
|
+
const SECTION_MAX = '45%'
|
|
50
|
+
|
|
51
|
+
/** Locale keys the GitBar reads off the `ui-tweaks` dictionary. */
|
|
52
|
+
type GitBarLabelKey =
|
|
53
|
+
| 'commitMessage' | 'diffFiles' | 'branchLocal' | 'branchRemote' | 'branchNew'
|
|
54
|
+
| 'branchNewPlaceholder' | 'branchCreate' | 'diffTitle' | 'diffOnly' | 'diffFull'
|
|
55
|
+
| 'commitTitle' | 'commitPlaceholder' | 'commitHint' | 'commitWillCommit' | 'commitViewDiff'
|
|
56
|
+
| 'commitEmpty' | 'commitCancel' | 'commitSubmit' | 'commitSubmitPush'
|
|
57
|
+
| 'commitBusy'
|
|
58
|
+
| 'dirty' | 'clean' | 'noChanges' | 'loading' | 'branchDelete' | 'branchDeleteConfirm'
|
|
59
|
+
| 'includeFile' | 'excludeFile' | 'branchPushRemote'
|
|
60
|
+
| 'branchRemoteDelete' | 'branchFrom' | 'branchFromHead' | 'branchGraph'
|
|
61
|
+
| 'branchRefresh' | 'branchCancel' | 'graphTitle'
|
|
62
|
+
| 'graphColGraph' | 'graphColCommit' | 'graphColSubject' | 'graphColAuthor' | 'graphColDate'
|
|
63
|
+
| 'openProject' | 'terminal' | 'openExplorer' | 'openVscode' | 'openIdea'
|
|
64
|
+
| 'openGoland' | 'openWebstorm' | 'openPycharm'
|
|
65
|
+
| 'termConnecting' | 'termExited' | 'termUnavailable' | 'termLost'
|
|
66
|
+
|
|
67
|
+
type Translate = (key: GitBarLabelKey) => string
|
|
68
|
+
|
|
69
|
+
interface ApiSuccess<T> { ok: true; value: T }
|
|
70
|
+
interface ApiFailure { ok: false; error: { code: string; message: string } }
|
|
71
|
+
|
|
72
|
+
interface GitFileChange {
|
|
73
|
+
path: string
|
|
74
|
+
status: string
|
|
75
|
+
added: number
|
|
76
|
+
deleted: number
|
|
77
|
+
untracked: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface GitSnapshot {
|
|
81
|
+
isRepo: boolean
|
|
82
|
+
/** Server could not resolve the target to a cwd — transient (the host is
|
|
83
|
+
* still materializing its session/workspace store right after a restart).
|
|
84
|
+
* Never cached as final; the client retries on a short backoff. */
|
|
85
|
+
unresolved?: boolean
|
|
86
|
+
cwd?: string
|
|
87
|
+
branch: string | null
|
|
88
|
+
detachedHead?: string
|
|
89
|
+
upstream?: string
|
|
90
|
+
ahead: number
|
|
91
|
+
behind: number
|
|
92
|
+
hasRemote: boolean
|
|
93
|
+
clean: boolean
|
|
94
|
+
files: GitFileChange[]
|
|
95
|
+
totalAdded: number
|
|
96
|
+
totalDeleted: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface GitBranches {
|
|
100
|
+
current: string | null
|
|
101
|
+
local: string[]
|
|
102
|
+
remote: string[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One commit row of the graph dialog. */
|
|
106
|
+
interface GitGraphCommit {
|
|
107
|
+
graph: string
|
|
108
|
+
fullHash: string
|
|
109
|
+
hash: string
|
|
110
|
+
subject: string
|
|
111
|
+
author: string
|
|
112
|
+
date: string
|
|
113
|
+
dateRelative: string
|
|
114
|
+
refs: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Git commit graph table returned by the `git/graph` endpoint. */
|
|
118
|
+
interface GitGraph {
|
|
119
|
+
commits: GitGraphCommit[]
|
|
120
|
+
truncated: boolean
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface DiffLine {
|
|
124
|
+
type: 'hunk' | 'add' | 'del' | 'ctx'
|
|
125
|
+
old: number | null
|
|
126
|
+
new: number | null
|
|
127
|
+
text: string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface GitDiffResult {
|
|
131
|
+
path: string
|
|
132
|
+
mode: 'hunk' | 'full'
|
|
133
|
+
lines: DiffLine[]
|
|
134
|
+
truncated: boolean
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function apiGet<T>(path: string): Promise<T> {
|
|
138
|
+
const response = await fetch(path, { credentials: 'same-origin' })
|
|
139
|
+
const body = await response.json() as ApiSuccess<T> | ApiFailure
|
|
140
|
+
if (!response.ok || !body.ok) {
|
|
141
|
+
const failure = body as ApiFailure
|
|
142
|
+
throw new Error(failure.error?.message ?? `Git request failed with HTTP ${response.status}`)
|
|
143
|
+
}
|
|
144
|
+
return body.value
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function apiPost<T>(path: string, payload: unknown): Promise<T> {
|
|
148
|
+
const response = await fetch(path, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
credentials: 'same-origin',
|
|
151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
152
|
+
body: JSON.stringify(payload),
|
|
153
|
+
})
|
|
154
|
+
const body = await response.json() as ApiSuccess<T> | ApiFailure
|
|
155
|
+
if (!response.ok || !body.ok) {
|
|
156
|
+
const failure = body as ApiFailure
|
|
157
|
+
throw new Error(failure.error?.message ?? `Git action failed with HTTP ${response.status}`)
|
|
158
|
+
}
|
|
159
|
+
return body.value
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Where git ops resolve their working directory: a real session id, or a
|
|
164
|
+
* workspace title for the hero/new-session screen (which has no materialized
|
|
165
|
+
* session yet — the server resolves the picked workspace's path instead).
|
|
166
|
+
*/
|
|
167
|
+
export interface GitTarget { session?: string; ws?: string }
|
|
168
|
+
|
|
169
|
+
function targetQuery(t: GitTarget): string {
|
|
170
|
+
if (t.session !== undefined) return `session=${encodeURIComponent(t.session)}`
|
|
171
|
+
if (t.ws !== undefined) return `ws=${encodeURIComponent(t.ws)}`
|
|
172
|
+
return ''
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function targetFields(t: GitTarget): Record<string, string> {
|
|
176
|
+
const out: Record<string, string> = {}
|
|
177
|
+
if (t.session !== undefined) out.session = t.session
|
|
178
|
+
if (t.ws !== undefined) out.ws = t.ws
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Basename of a cwd path ('' / undefined → '—'), for the chip's folder label. */
|
|
183
|
+
function basenameOf(path: string | undefined): string {
|
|
184
|
+
if (path === undefined || path === '') return '—'
|
|
185
|
+
const norm = path.replace(/[\\/]+$/, '')
|
|
186
|
+
const idx = Math.max(norm.lastIndexOf('\\'), norm.lastIndexOf('/'))
|
|
187
|
+
return idx >= 0 ? norm.slice(idx + 1) : norm
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// GitBar styles — DSH theme tokens only (light + dark).
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export const GITBAR_CSS = `
|
|
195
|
+
/* DSH does not force border-box globally; normalize it for the whole GitBar
|
|
196
|
+
subtree (including the body-portaled panels/modal), or widths overflow. */
|
|
197
|
+
.gbar,.gbar *,.gbar-side,.gbar-side *,.gbar-modal-wrap,.gbar-modal-wrap *,.gbar-notice,.gbar-notice *{box-sizing:border-box}
|
|
198
|
+
/* Anchor around each pill inside the composer tool row
|
|
199
|
+
(conversation.input.left / .right): the branch pill's containing block, so
|
|
200
|
+
its popup opens from the pill itself. flex:0 1 auto + min-width:0 let the
|
|
201
|
+
pill compress when the row is squeezed instead of spilling over neighbours. */
|
|
202
|
+
.gbar{position:relative;display:inline-flex;align-items:center;gap:6px;flex:0 1 auto;min-width:0}
|
|
203
|
+
/* Pills match the input bar's resident chrome (access mode / model select):
|
|
204
|
+
28px tall, fully-rounded, transparent at rest with a soft hover fill.
|
|
205
|
+
overflow:hidden clips the pill's content while it compresses. */
|
|
206
|
+
.gbar-pill{
|
|
207
|
+
display:inline-flex;align-items:center;gap:6px;
|
|
208
|
+
height:28px;padding:0 8px;min-width:0;overflow:hidden;
|
|
209
|
+
background:transparent;
|
|
210
|
+
border:none;
|
|
211
|
+
border-radius:24px;
|
|
212
|
+
color:var(--dsw-alias-label-secondary);
|
|
213
|
+
font:inherit;font-size:13px;font-weight:500;line-height:20px;
|
|
214
|
+
cursor:pointer;
|
|
215
|
+
transition:background .15s ease;
|
|
216
|
+
white-space:nowrap;
|
|
217
|
+
-webkit-user-select:none;user-select:none;
|
|
218
|
+
}
|
|
219
|
+
.gbar-pill:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
220
|
+
.gbar-pill:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}
|
|
221
|
+
/* Long branch names ellipsize instead of stretching the row. */
|
|
222
|
+
.gbar-pill .gbar-branch{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:140px}
|
|
223
|
+
/* Icons match the resident access-mode trigger exactly: 14px, full color,
|
|
224
|
+
no fade, and box-centered (same vertical centre as the access icon). */
|
|
225
|
+
.gbar-pill .gbar-ico{width:14px;height:14px;flex:none;opacity:1}
|
|
226
|
+
.gbar-pill .gbar-caret{font-size:8px;color:var(--dsw-alias-label-tertiary);margin-left:1px}
|
|
227
|
+
.gbar-pill .gbar-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warn-primary);flex:none}
|
|
228
|
+
.gbar-pill .gbar-add{color:var(--dsw-alias-state-success-primary);font-weight:600;font-variant-numeric:tabular-nums}
|
|
229
|
+
.gbar-pill .gbar-del{color:var(--dsw-alias-state-error-primary);font-weight:600;font-variant-numeric:tabular-nums}
|
|
230
|
+
.gbar-pill .gbar-meta{color:var(--dsw-alias-label-secondary)}
|
|
231
|
+
.gbar-pill .gbar-hint{color:var(--dsw-alias-label-tertiary);font-weight:400}
|
|
232
|
+
.gbar-pill.gbar-active{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-business-primary)}
|
|
233
|
+
.gbar-pill.gbar-active .gbar-hint{color:var(--dsw-alias-state-business-primary)}
|
|
234
|
+
.gbar-pill:disabled{opacity:.5;cursor:default}
|
|
235
|
+
/* The composer tool row is an inline-size container (DSH sets
|
|
236
|
+
container-type:inline-size on it), so these container queries track the
|
|
237
|
+
row's actual width. When the stretched diff panel pushes the conversation
|
|
238
|
+
column and the row tightens — the same squeeze that makes the resident
|
|
239
|
+
access / model chrome collapse its labels to icons — the pills follow suit
|
|
240
|
+
instead of ever overlapping their neighbours: first drop the "· K 个文件"
|
|
241
|
+
meta, then go icon-only (before the row gets narrow enough for the branch
|
|
242
|
+
name to collide with the access control). */
|
|
243
|
+
@container (max-width: 700px){
|
|
244
|
+
.gbar-pill .gbar-meta{display:none}
|
|
245
|
+
}
|
|
246
|
+
@container (max-width: 620px){
|
|
247
|
+
.gbar-pill .gbar-text{display:none}
|
|
248
|
+
.gbar-pill .gbar-caret{display:none}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* branch popup — the chip lives in the session HEADER now, so the menu opens
|
|
252
|
+
DOWNWARD (top-anchored, small arrow pointing up). Pinned header (current
|
|
253
|
+
branch + worktree state) and pinned action bar surround a freely scrolling
|
|
254
|
+
branch list. */
|
|
255
|
+
.gbar-pop{
|
|
256
|
+
position:absolute;left:0;top:calc(100% + 8px);z-index:120;
|
|
257
|
+
width:300px;
|
|
258
|
+
background:var(--dsw-alias-bg-layer-1);
|
|
259
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:14px;
|
|
260
|
+
box-shadow:var(--dsw-shadow-lv2);
|
|
261
|
+
display:flex;flex-direction:column;overflow:hidden;
|
|
262
|
+
animation:gpop-down .16s cubic-bezier(.32,.72,0,1);
|
|
263
|
+
}
|
|
264
|
+
.gbar-pop::before{content:"";position:absolute;top:-5px;left:22px;width:9px;height:9px;
|
|
265
|
+
background:var(--dsw-alias-bg-layer-1);border-left:1px solid var(--dsw-alias-border-l2);
|
|
266
|
+
border-top:1px solid var(--dsw-alias-border-l2);transform:rotate(45deg)}
|
|
267
|
+
@keyframes gpop-down{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:none}}
|
|
268
|
+
.gbar-pop-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-module-platform)}
|
|
269
|
+
.gbar-pop-head .gbar-bicon{width:14px;height:14px;flex:none}
|
|
270
|
+
.gbar-pop-head .gbar-curname{display:flex;align-items:center;gap:7px;min-width:0;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
271
|
+
.gbar-pop-head .gbar-curname .gbar-bicon{color:var(--dsw-alias-state-business-primary)}
|
|
272
|
+
.gbar-pop-head .gbar-curname>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
273
|
+
.gbar-pop-head .gbar-state{margin-left:auto;flex:none;display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
|
|
274
|
+
.gbar-pop-head .gbar-dot{width:6px;height:6px;border-radius:50%;flex:none}
|
|
275
|
+
.gbar-pop-head .gbar-dot.gbar-dirty{background:var(--dsw-alias-state-warn-primary)}
|
|
276
|
+
.gbar-pop-head .gbar-dot.gbar-clean{background:var(--dsw-alias-state-success-primary)}
|
|
277
|
+
.gbar-pop-body{max-height:min(330px,calc(100vh - 250px));overflow-y:auto;padding:3px 6px 5px;scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent) transparent}
|
|
278
|
+
.gbar-pop-body::-webkit-scrollbar{width:5px}
|
|
279
|
+
.gbar-pop-body::-webkit-scrollbar-track{background:transparent}
|
|
280
|
+
.gbar-pop-body::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent);border-radius:4px}
|
|
281
|
+
.gbar-pop .gbar-sec{display:flex;align-items:center;gap:6px;font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);padding:8px 9px 3px}
|
|
282
|
+
.gbar-pop .gbar-sec .gbar-count{margin-left:auto;font-size:10px;font-weight:500;letter-spacing:0;line-height:15px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform);border-radius:999px;padding:0 7px}
|
|
283
|
+
.gbar-pop .gbar-loading{display:flex;align-items:center;justify-content:center;gap:8px;padding:14px 9px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
|
284
|
+
.gbar-pop .gbar-row{
|
|
285
|
+
display:flex;align-items:center;gap:8px;width:100%;
|
|
286
|
+
padding:6px 8px;border:none;border-radius:8px;
|
|
287
|
+
background:transparent;color:var(--dsw-alias-label-primary);
|
|
288
|
+
font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
|
|
289
|
+
transition:background .12s ease;
|
|
290
|
+
}
|
|
291
|
+
.gbar-pop .gbar-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
292
|
+
.gbar-pop .gbar-row.gbar-cur{color:var(--dsw-alias-state-business-primary);font-weight:600}
|
|
293
|
+
.gbar-pop .gbar-row .gbar-bicon{width:13px;height:13px;flex:none;color:var(--dsw-alias-label-tertiary);opacity:.85}
|
|
294
|
+
.gbar-pop .gbar-row.gbar-cur .gbar-bicon{color:var(--dsw-alias-state-business-primary);opacity:1}
|
|
295
|
+
.gbar-pop .gbar-row .gbar-check{margin-left:auto;color:var(--dsw-alias-state-business-primary);font-weight:700}
|
|
296
|
+
.gbar-pop .gbar-row .gbar-rm{flex:none;font-size:10px;line-height:15px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform);border-radius:999px;padding:0 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px}
|
|
297
|
+
/* branch row wrapper: main switch button + delete button */
|
|
298
|
+
.gbar-pop .gbar-rowwrap{display:flex;align-items:center;gap:2px;border-radius:8px;transition:background .12s ease}
|
|
299
|
+
.gbar-pop .gbar-rowwrap:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
300
|
+
.gbar-pop .gbar-rowwrap .gbar-row{background:transparent}
|
|
301
|
+
.gbar-pop .gbar-del{
|
|
302
|
+
flex:none;display:inline-flex;align-items:center;justify-content:center;
|
|
303
|
+
width:26px;height:26px;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);
|
|
304
|
+
font:inherit;font-size:10.5px;cursor:pointer;border-radius:7px;margin-right:4px;
|
|
305
|
+
transition:color .12s ease,background .12s ease;
|
|
306
|
+
}
|
|
307
|
+
.gbar-pop .gbar-del svg{width:13px;height:13px;display:block}
|
|
308
|
+
.gbar-pop .gbar-del:hover{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
|
|
309
|
+
.gbar-pop .gbar-del.gbar-arm{width:auto;padding:0 8px;color:var(--dsw-alias-state-error-primary);font-weight:600;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
|
|
310
|
+
.gbar-pushrow{display:flex;align-items:center;gap:7px;padding:0 8px 7px}
|
|
311
|
+
.gbar-pushrow .gbar-pushlabel{font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
312
|
+
.gbar-pop .gbar-newrow{display:flex;gap:6px;padding:8px 6px 6px}
|
|
313
|
+
.gbar-pop input{
|
|
314
|
+
flex:1;min-width:0;height:32px;padding:0 11px;
|
|
315
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:9px;
|
|
316
|
+
background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
|
|
317
|
+
font:inherit;font-size:13px;outline:none;
|
|
318
|
+
}
|
|
319
|
+
.gbar-pop input:focus{border-color:var(--dsw-alias-state-business-primary)}
|
|
320
|
+
.gbar-pop input::placeholder{color:var(--dsw-alias-label-tertiary)}
|
|
321
|
+
.gbar-mini{
|
|
322
|
+
flex:none;height:32px;padding:0 14px;border:none;border-radius:9px;
|
|
323
|
+
background:var(--dsw-alias-state-business-primary);color:#fff;
|
|
324
|
+
font:inherit;font-size:13px;font-weight:500;cursor:pointer;
|
|
325
|
+
}
|
|
326
|
+
.gbar-mini:hover{opacity:.92}
|
|
327
|
+
.gbar-mini:disabled{opacity:.5;cursor:default}
|
|
328
|
+
|
|
329
|
+
/* branch popup action entries (new branch / graph) — pinned below the
|
|
330
|
+
scrolling list; the border-top separates it from the body. */
|
|
331
|
+
.gbar-actions{display:flex;gap:6px;padding:8px;border-top:1px solid var(--dsw-alias-border-l1)}
|
|
332
|
+
.gbar-act{
|
|
333
|
+
flex:1;display:inline-flex;align-items:center;justify-content:center;gap:7px;
|
|
334
|
+
height:34px;border:none;border-radius:10px;
|
|
335
|
+
background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);
|
|
336
|
+
font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;
|
|
337
|
+
}
|
|
338
|
+
.gbar-act:hover{background:var(--dsw-alias-interactive-bg-hover-solid)}
|
|
339
|
+
.gbar-act svg{width:14px;height:14px;opacity:.9;flex:none}
|
|
340
|
+
/* base-branch selector (new-branch dialog) */
|
|
341
|
+
.gbar-baserow{display:flex;align-items:center;gap:7px;padding:0}
|
|
342
|
+
.gbar-baselabel{font-size:12px;color:var(--dsw-alias-label-secondary);flex:none}
|
|
343
|
+
.gbar-base{
|
|
344
|
+
flex:1;min-width:0;height:32px;padding:0 10px;
|
|
345
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:9px;
|
|
346
|
+
background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
|
|
347
|
+
font:inherit;font-size:12.5px;outline:none;cursor:pointer;
|
|
348
|
+
}
|
|
349
|
+
.gbar-base:focus{border-color:var(--dsw-alias-state-business-primary)}
|
|
350
|
+
.gbar-modal input{
|
|
351
|
+
width:100%;height:38px;padding:0 13px;
|
|
352
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:10px;
|
|
353
|
+
background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
|
|
354
|
+
font:inherit;font-size:13.5px;outline:none;
|
|
355
|
+
}
|
|
356
|
+
.gbar-modal input:focus{border-color:var(--dsw-alias-state-business-primary)}
|
|
357
|
+
.gbar-modal input::placeholder{color:var(--dsw-alias-label-tertiary)}
|
|
358
|
+
.gbar-modal .gbar-head{padding-bottom:10px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
359
|
+
/* "push to remote" row becomes an inset card so it reads as one option group. */
|
|
360
|
+
.gbar-modal .gbar-pushrow{padding:9px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-2)}
|
|
361
|
+
.gbar-modal .gbar-pushrow .gbar-pushlabel{font-size:12.5px;color:var(--dsw-alias-label-primary)}
|
|
362
|
+
.gbar-x:disabled{opacity:.5;cursor:default}
|
|
363
|
+
/* commit-graph dialog */
|
|
364
|
+
.gbar-modal.gbar-graph-modal{width:min(760px,calc(100vw - 40px));gap:10px}
|
|
365
|
+
/* Refresh sits immediately left of close, both pinned to the right edge: the
|
|
366
|
+
refresh takes the auto margin (specificity .gbar-x.gbar-refresh beats the
|
|
367
|
+
.gbar-x override below), close drops it so they stay adjacent. */
|
|
368
|
+
.gbar-modal.gbar-graph-modal .gbar-head .gbar-x.gbar-refresh{margin-left:auto}
|
|
369
|
+
.gbar-modal.gbar-graph-modal .gbar-head .gbar-x{margin-left:0}
|
|
370
|
+
.gbar-modal.gbar-graph-modal .gbar-x svg{width:15px;height:15px;display:block}
|
|
371
|
+
.gbar-graph-empty{padding:28px 12px;text-align:center;font-size:13px;color:var(--dsw-alias-label-tertiary)}
|
|
372
|
+
.gbar-graph-table{display:flex;flex-direction:column;max-height:min(60vh,540px);overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-2)}
|
|
373
|
+
.gbar-graph-row{display:grid;grid-template-columns:62px 1fr 96px 90px;align-items:center;gap:8px;padding:5px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);transition:background .12s ease}
|
|
374
|
+
.gbar-graph-row:last-child{border-bottom:none}
|
|
375
|
+
/* Zebra striping for scanability; the hover rule matches its specificity and
|
|
376
|
+
comes later in the sheet, so pointing at a row still wins. */
|
|
377
|
+
.gbar-graph-row:not(.gbar-graph-head):nth-child(odd){background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 35%,transparent)}
|
|
378
|
+
.gbar-graph-row:not(.gbar-graph-head):hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
379
|
+
.gbar-graph-head{position:sticky;top:0;background:var(--dsw-alias-bg-module-platform);font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);z-index:1;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
380
|
+
.gbar-c-hash code{font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform);border-radius:6px;padding:2px 6px}
|
|
381
|
+
.gbar-c-subject{min-width:0;display:flex;align-items:center;gap:8px}
|
|
382
|
+
.gbar-subject{font-size:12.5px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
383
|
+
.gbar-refs{flex:none;font-size:10.5px;color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);border-radius:999px;padding:1px 8px;white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
|
|
384
|
+
.gbar-c-author{font-size:12px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
385
|
+
.gbar-c-date{font-size:11.5px;color:var(--dsw-alias-label-tertiary);text-align:right;white-space:nowrap}
|
|
386
|
+
|
|
387
|
+
/* diff side panel — fixed on the right; the conversation is pushed left via
|
|
388
|
+
#root { margin-right } so the panel never overlaps the message column. */
|
|
389
|
+
.gbar-side{
|
|
390
|
+
position:fixed;right:0;top:0;bottom:0;z-index:80;
|
|
391
|
+
display:flex;flex-direction:column;overflow:hidden;
|
|
392
|
+
/* bg-base instead of bg-layer-1: in dark mode layer-1 (#232324) reads gray
|
|
393
|
+
next to the conversation's base (#151517); base matches it exactly, and in
|
|
394
|
+
light mode both aliases are #fff so nothing changes. */
|
|
395
|
+
background:var(--dsw-alias-bg-base);
|
|
396
|
+
border-left:1px solid var(--dsw-alias-border-l2);
|
|
397
|
+
box-shadow:var(--dsw-shadow-lv2);
|
|
398
|
+
animation:gbar-in .18s cubic-bezier(.32,.72,0,1);
|
|
399
|
+
}
|
|
400
|
+
/* drag handle on the panel's left edge */
|
|
401
|
+
.gbar-resize{
|
|
402
|
+
position:absolute;left:-5px;top:0;bottom:0;width:10px;cursor:col-resize;
|
|
403
|
+
touch-action:none;
|
|
404
|
+
}
|
|
405
|
+
.gbar-resize::after{
|
|
406
|
+
content:"";position:absolute;left:4px;top:0;bottom:0;width:2px;
|
|
407
|
+
background:transparent;transition:background .15s ease;
|
|
408
|
+
}
|
|
409
|
+
.gbar-side:hover .gbar-resize::after{background:var(--dsw-alias-border-l2)}
|
|
410
|
+
.gbar-resize:active::after{background:var(--dsw-alias-state-business-primary)}
|
|
411
|
+
@keyframes gbar-in{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}
|
|
412
|
+
.gbar-side-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
|
413
|
+
.gbar-side-head .gbar-title{font-size:13.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
414
|
+
.gbar-side-head .gbar-sub{font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none;font-variant-numeric:tabular-nums}
|
|
415
|
+
.gbar-side-head .gbar-sub .gbar-a{color:var(--dsw-alias-state-success-primary)}
|
|
416
|
+
.gbar-side-head .gbar-sub .gbar-d{color:var(--dsw-alias-state-error-primary)}
|
|
417
|
+
.gbar-side-head .gbar-spacer{flex:1}
|
|
418
|
+
.gbar-seg{display:inline-flex;padding:3px;gap:2px;border-radius:10px;background:var(--dsw-alias-bg-module-platform);flex:none}
|
|
419
|
+
.gbar-seg button{border:none;border-radius:8px;padding:4px 10px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
|
|
420
|
+
.gbar-seg button.gbar-on{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-shadow-lv1)}
|
|
421
|
+
.gbar-side-x{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:15px;cursor:pointer;width:28px;height:28px;border-radius:8px;flex:none}
|
|
422
|
+
.gbar-side-x:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
423
|
+
.gbar-side-body{flex:1;overflow:hidden;display:flex;flex-direction:column;min-height:0}
|
|
424
|
+
/* The three stacked sections (files / diff / commit) are sized by drag: the two
|
|
425
|
+
neighbours carry an explicit inline height and the diff pane absorbs whatever
|
|
426
|
+
is left, so a drag can never overflow the panel. The dividing hairlines are
|
|
427
|
+
painted by the splitters, not by the sections' own borders. */
|
|
428
|
+
.gbar-files{flex:none;overflow-y:auto;scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent) transparent}
|
|
429
|
+
.gbar-files::-webkit-scrollbar{width:5px}
|
|
430
|
+
.gbar-files::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent);border-radius:4px}
|
|
431
|
+
/* horizontal drag splitters between the sections */
|
|
432
|
+
.gbar-vsplit{
|
|
433
|
+
flex:none;position:relative;height:7px;
|
|
434
|
+
cursor:row-resize;touch-action:none;-webkit-user-select:none;user-select:none;
|
|
435
|
+
}
|
|
436
|
+
.gbar-vsplit::after{
|
|
437
|
+
content:"";position:absolute;left:0;right:0;top:3px;height:1px;
|
|
438
|
+
background:var(--dsw-alias-border-l1);transition:background .15s ease,height .15s ease;
|
|
439
|
+
}
|
|
440
|
+
.gbar-vsplit:hover::after{background:var(--dsw-alias-border-l2);height:2px}
|
|
441
|
+
.gbar-vsplit.gbar-dragging::after{background:var(--dsw-alias-state-business-primary);height:2px}
|
|
442
|
+
.gbar-file{
|
|
443
|
+
display:flex;align-items:center;gap:6px;width:100%;
|
|
444
|
+
padding:3px 14px;background:transparent;
|
|
445
|
+
}
|
|
446
|
+
.gbar-file .gbar-file-main{
|
|
447
|
+
flex:1;min-width:0;display:flex;align-items:center;gap:9px;
|
|
448
|
+
padding:5px 4px;border:none;border-radius:7px;background:transparent;
|
|
449
|
+
color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;
|
|
450
|
+
}
|
|
451
|
+
.gbar-file .gbar-file-main:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
452
|
+
.gbar-file.gbar-on .gbar-file-main{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 9%,transparent);box-shadow:inset 2px 0 0 var(--dsw-alias-state-business-primary)}
|
|
453
|
+
.gbar-file.gbar-excl{opacity:.55}
|
|
454
|
+
.gbar-chk{
|
|
455
|
+
flex:none;width:16px;height:16px;padding:0;
|
|
456
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:5px;
|
|
457
|
+
background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-business-primary);
|
|
458
|
+
font-size:11px;line-height:1;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;
|
|
459
|
+
}
|
|
460
|
+
.gbar-chk:hover{border-color:var(--dsw-alias-state-business-primary)}
|
|
461
|
+
.gbar-chk.gbar-off{color:transparent}
|
|
462
|
+
.gbar-file .gbar-st{
|
|
463
|
+
flex:none;min-width:17px;height:17px;padding:0 4px;
|
|
464
|
+
display:inline-flex;align-items:center;justify-content:center;
|
|
465
|
+
font-weight:700;font-size:10.5px;border-radius:5px;
|
|
466
|
+
}
|
|
467
|
+
.gbar-file .gbar-st.gbar-m{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 13%,transparent)}
|
|
468
|
+
.gbar-file .gbar-st.gbar-a{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 13%,transparent)}
|
|
469
|
+
.gbar-file .gbar-st.gbar-d{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}
|
|
470
|
+
.gbar-file .gbar-st.gbar-u{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-module-platform)}
|
|
471
|
+
.gbar-file .gbar-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:12px}
|
|
472
|
+
/* Directory part dims, filename stays bright — GitHub-style path rendering. */
|
|
473
|
+
.gbar-file .gbar-path .gbar-dir{color:var(--dsw-alias-label-tertiary)}
|
|
474
|
+
.gbar-file .gbar-nums{flex:none;font-variant-numeric:tabular-nums;font-size:11.5px}
|
|
475
|
+
.gbar-file .gbar-nums .gbar-a{color:var(--dsw-alias-state-success-primary)}
|
|
476
|
+
.gbar-file .gbar-nums .gbar-d{color:var(--dsw-alias-state-error-primary)}
|
|
477
|
+
.gbar-diff{
|
|
478
|
+
flex:1;min-height:0;overflow:auto;
|
|
479
|
+
font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:12px;line-height:1.7;padding:8px 0;
|
|
480
|
+
scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent) transparent;
|
|
481
|
+
}
|
|
482
|
+
.gbar-diff::-webkit-scrollbar{width:5px;height:5px}
|
|
483
|
+
.gbar-diff::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 35%,transparent);border-radius:4px}
|
|
484
|
+
/* Hunk header: a centered pill strip (GitHub-style), gutter numbers hidden but
|
|
485
|
+
still occupying space so the code column stays aligned. */
|
|
486
|
+
.gbar-diff .gbar-line.gbar-hunk{background:transparent;padding-top:7px;padding-bottom:3px}
|
|
487
|
+
.gbar-diff .gbar-line.gbar-hunk .gbar-ln{visibility:hidden}
|
|
488
|
+
.gbar-diff .gbar-line.gbar-hunk .gbar-code{color:var(--dsw-alias-state-business-primary)}
|
|
489
|
+
.gbar-diff .gbar-line{display:flex;align-items:center;min-height:21px;padding:0 14px 0 0;white-space:pre}
|
|
490
|
+
.gbar-diff .gbar-line .gbar-ln{flex:none;width:34px;text-align:right;padding-right:8px;color:var(--dsw-alias-label-tertiary);font-size:11px;-webkit-user-select:none;user-select:none}
|
|
491
|
+
.gbar-diff .gbar-line .gbar-ln+.gbar-ln{border-right:1px solid var(--dsw-alias-border-l1);margin-right:10px}
|
|
492
|
+
.gbar-diff .gbar-line .gbar-code{flex:1;white-space:pre;color:var(--dsw-alias-label-primary);padding-right:14px}
|
|
493
|
+
.gbar-diff .gbar-line.gbar-add{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 13%,transparent);box-shadow:inset 2px 0 0 color-mix(in srgb,var(--dsw-alias-state-success-primary) 55%,transparent)}
|
|
494
|
+
.gbar-diff .gbar-line.gbar-add .gbar-ln{color:var(--dsw-alias-state-success-primary)}
|
|
495
|
+
.gbar-diff .gbar-line.gbar-del{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);box-shadow:inset 2px 0 0 color-mix(in srgb,var(--dsw-alias-state-error-primary) 50%,transparent)}
|
|
496
|
+
.gbar-diff .gbar-line.gbar-del .gbar-ln{color:var(--dsw-alias-state-error-primary)}
|
|
497
|
+
.gbar-diff .gbar-line.gbar-ctx .gbar-code{color:var(--dsw-alias-label-secondary)}
|
|
498
|
+
.gbar-diff .gbar-empty{display:flex;align-items:center;justify-content:center;min-height:140px;font-size:12px;color:var(--dsw-alias-label-tertiary)}
|
|
499
|
+
/* The hairline above the foot is drawn by the commit splitter that precedes it. */
|
|
500
|
+
.gbar-side-foot{flex:none;padding:8px 14px;font-size:11.5px;color:var(--dsw-alias-label-tertiary);display:flex;gap:8px;align-items:center}
|
|
501
|
+
.gbar-side-foot .gbar-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-warn-primary);flex:none}
|
|
502
|
+
/* commit row inside the diff panel — height is drag-adjustable, so the message
|
|
503
|
+
field stretches to fill whatever the user gives this section. */
|
|
504
|
+
.gbar-side-commit{flex:none;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:7px;padding:9px 12px;border-top:1px solid var(--dsw-alias-border-l1)}
|
|
505
|
+
.gbar-side-commit .gbar-crow{flex:1;min-height:0;display:flex;align-items:stretch;gap:6px}
|
|
506
|
+
.gbar-side-commit textarea{
|
|
507
|
+
flex:1;min-width:0;min-height:30px;padding:6px 10px;resize:none;
|
|
508
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:8px;
|
|
509
|
+
background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
|
|
510
|
+
font:inherit;font-size:12px;line-height:1.6;outline:none;overflow-y:auto;
|
|
511
|
+
}
|
|
512
|
+
.gbar-side-commit textarea:focus{border-color:var(--dsw-alias-state-business-primary)}
|
|
513
|
+
.gbar-side-commit textarea::placeholder{color:var(--dsw-alias-label-tertiary)}
|
|
514
|
+
.gbar-side-commit .gbar-actions{flex:none;display:flex;align-items:center;justify-content:flex-end;gap:6px}
|
|
515
|
+
.gbar-side-commit .gbar-btn{height:28px;padding:0 12px;font-size:12px;border-radius:8px}
|
|
516
|
+
|
|
517
|
+
/* commit modal */
|
|
518
|
+
.gbar-modal-wrap{
|
|
519
|
+
position:fixed;inset:0;z-index:120;
|
|
520
|
+
display:flex;align-items:center;justify-content:center;
|
|
521
|
+
background:rgba(0,0,0,.35);
|
|
522
|
+
}
|
|
523
|
+
.gbar-modal{
|
|
524
|
+
width:min(520px,calc(100vw - 40px));max-height:calc(100vh - 48px);overflow:hidden auto;
|
|
525
|
+
background:var(--dsw-alias-bg-layer-1);
|
|
526
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:16px;
|
|
527
|
+
box-shadow:var(--dsw-shadow-lv2);
|
|
528
|
+
padding:18px;
|
|
529
|
+
display:flex;flex-direction:column;gap:12px;
|
|
530
|
+
animation:gbar-in .18s cubic-bezier(.32,.72,0,1);
|
|
531
|
+
}
|
|
532
|
+
.gbar-modal .gbar-head{display:flex;align-items:center;gap:10px}
|
|
533
|
+
.gbar-modal .gbar-head .gbar-title{font-size:15px;font-weight:600}
|
|
534
|
+
.gbar-modal .gbar-head .gbar-branch{font-size:12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform);border-radius:999px;padding:3px 10px}
|
|
535
|
+
.gbar-modal .gbar-head .gbar-x{margin-left:auto;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:16px;cursor:pointer;width:28px;height:28px;border-radius:8px}
|
|
536
|
+
.gbar-modal .gbar-head .gbar-x:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
537
|
+
.gbar-modal textarea{
|
|
538
|
+
width:100%;min-height:64px;height:auto;max-height:220px;resize:none;
|
|
539
|
+
overflow-y:auto;scrollbar-width:none;
|
|
540
|
+
border:1px solid var(--dsw-alias-border-l2);border-radius:12px;
|
|
541
|
+
background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
|
|
542
|
+
font:inherit;font-size:14px;line-height:1.6;padding:10px 14px;outline:none;
|
|
543
|
+
}
|
|
544
|
+
.gbar-modal textarea::-webkit-scrollbar{display:none}
|
|
545
|
+
.gbar-modal textarea:focus{border-color:var(--dsw-alias-state-business-primary)}
|
|
546
|
+
.gbar-modal textarea::placeholder{color:var(--dsw-alias-label-tertiary)}
|
|
547
|
+
.gbar-modal .gbar-hint{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:1.6}
|
|
548
|
+
.gbar-modal .gbar-files-head{display:flex;align-items:center;gap:8px;font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);margin-top:2px}
|
|
549
|
+
.gbar-modal .gbar-files-head .gbar-hint{font-size:11px;font-weight:400;text-transform:none;letter-spacing:0}
|
|
550
|
+
.gbar-modal .gbar-files{
|
|
551
|
+
display:flex;flex-direction:column;gap:1px;max-height:132px;overflow-y:auto;
|
|
552
|
+
border:1px solid var(--dsw-alias-border-l1);border-radius:10px;
|
|
553
|
+
background:var(--dsw-alias-bg-layer-2);padding:4px;
|
|
554
|
+
}
|
|
555
|
+
.gbar-modal .gbar-file{
|
|
556
|
+
display:flex;align-items:center;gap:6px;width:100%;
|
|
557
|
+
padding:2px 6px;border-radius:7px;background:transparent;
|
|
558
|
+
}
|
|
559
|
+
.gbar-modal .gbar-file .gbar-file-main{
|
|
560
|
+
padding:4px 4px;font-size:12px;border-radius:6px;
|
|
561
|
+
}
|
|
562
|
+
.gbar-modal .gbar-file .gbar-file-main:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
563
|
+
.gbar-modal .gbar-file.gbar-on .gbar-file-main{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}
|
|
564
|
+
.gbar-modal .gbar-file .gbar-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:11.5px}
|
|
565
|
+
.gbar-modal .gbar-file .gbar-goto{flex:none;color:var(--dsw-alias-label-tertiary);font-size:10px}
|
|
566
|
+
.gbar-modal .gbar-file.gbar-on .gbar-goto{color:var(--dsw-alias-state-business-primary)}
|
|
567
|
+
.gbar-modal .gbar-foot{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:2px;flex-wrap:wrap}
|
|
568
|
+
.gbar-btn{
|
|
569
|
+
height:34px;padding:0 16px;border:none;border-radius:10px;
|
|
570
|
+
font:inherit;font-size:13.5px;cursor:pointer;
|
|
571
|
+
display:inline-flex;align-items:center;gap:6px;
|
|
572
|
+
}
|
|
573
|
+
.gbar-btn.gbar-ghost{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary)}
|
|
574
|
+
.gbar-btn.gbar-ghost:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-solid)}
|
|
575
|
+
.gbar-btn.gbar-soft{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);color:var(--dsw-alias-state-business-primary)}
|
|
576
|
+
.gbar-btn.gbar-soft:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 18%,transparent)}
|
|
577
|
+
.gbar-btn.gbar-primary{background:var(--dsw-alias-state-business-primary);color:#fff;font-weight:500}
|
|
578
|
+
.gbar-btn.gbar-primary:hover:not(:disabled){opacity:.92}
|
|
579
|
+
/* Disabled buttons (e.g. while a task is running) must NOT light up on hover —
|
|
580
|
+
they keep their muted look, though the tooltip hint still shows. */
|
|
581
|
+
.gbar-btn:disabled{opacity:.5;cursor:default}
|
|
582
|
+
.gbar-btn:disabled:hover{opacity:.5}
|
|
583
|
+
.gbar-notice{
|
|
584
|
+
position:fixed;z-index:150;left:50%;bottom:28px;transform:translateX(-50%);
|
|
585
|
+
max-width:min(520px,90vw);padding:9px 16px;border-radius:10px;font-size:12.5px;line-height:1.5;
|
|
586
|
+
box-shadow:var(--dsw-shadow-lv2);animation:gbar-in .18s cubic-bezier(.32,.72,0,1);
|
|
587
|
+
}
|
|
588
|
+
.gbar-notice.gbar-ok{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 16%,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-state-success-primary)}
|
|
589
|
+
.gbar-notice.gbar-err{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 14%,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-state-error-primary)}
|
|
590
|
+
.gbar-spin{width:12px;height:12px;border-radius:50%;border:2px solid color-mix(in srgb,var(--dsw-alias-label-tertiary) 30%,transparent);border-top-color:currentColor;animation:gbar-spin .7s linear infinite}
|
|
591
|
+
@keyframes gbar-spin{to{transform:rotate(360deg)}}
|
|
592
|
+
|
|
593
|
+
/* branch capsule — the only interactive half of the header seat: 22px tall
|
|
594
|
+
like the resident agent-preset badge (same 12px type), fully-rounded,
|
|
595
|
+
quiet fill on hover, active tint while the popup is open. */
|
|
596
|
+
.gbar-chip{position:relative;display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 9px;
|
|
597
|
+
border:none;border-radius:16px;background:transparent;color:var(--dsw-alias-label-secondary);
|
|
598
|
+
font:inherit;font-size:12px;font-weight:400;line-height:22px;cursor:pointer;white-space:nowrap;
|
|
599
|
+
-webkit-user-select:none;user-select:none;transition:background .15s ease}
|
|
600
|
+
.gbar-chip:hover,.gbar-chip:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}
|
|
601
|
+
.gbar-chip.gbar-open{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-state-business-primary)}
|
|
602
|
+
.gbar-chip svg{width:14px;height:14px;flex:none;opacity:.9}
|
|
603
|
+
.gbar-chip .gbar-bname{max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
604
|
+
/* folder label — plain context beside the capsule: NO background (not a
|
|
605
|
+
capsule), not clickable and no hover, so only the branch carries the pill.
|
|
606
|
+
Same 22px / 12px type as the agent-preset badge beside it, and like that
|
|
607
|
+
label the folder name stays selectable so it can be copied with the mouse;
|
|
608
|
+
cursor:auto lets the browser show the text caret over the selectable name,
|
|
609
|
+
exactly like that label. */
|
|
610
|
+
.gbar-fchip{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 2px;
|
|
611
|
+
color:var(--dsw-alias-label-secondary);
|
|
612
|
+
font:inherit;font-size:12px;font-weight:400;line-height:22px;white-space:nowrap;
|
|
613
|
+
-webkit-user-select:text;user-select:text;cursor:auto}
|
|
614
|
+
.gbar-fchip svg{width:14px;height:14px;flex:none;opacity:.9}
|
|
615
|
+
.gbar-fchip .gbar-folder{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
616
|
+
/* wraps the interactive branch capsule so its popup anchors to the capsule,
|
|
617
|
+
not to the whole folder + branch row */
|
|
618
|
+
.gbar-bwrap{position:relative;display:inline-flex;align-items:center}
|
|
619
|
+
|
|
620
|
+
/* header utilities row: 打开项目 / 终端 / 差异 */
|
|
621
|
+
.gbar-hicons{margin-left:auto;display:inline-flex;align-items:center;gap:2px;position:relative}
|
|
622
|
+
/* The three header utility icons are injected into the title row's utilities
|
|
623
|
+
slot (beside "Session log"), but they belong in the view-tabs row (对话 /
|
|
624
|
+
轨迹) right below it. The session header is their containing block: anchor
|
|
625
|
+
them to the right edge, vertically centred on the 16px tab text line that
|
|
626
|
+
starts under the 32px title row (12px header top padding + 32px title row
|
|
627
|
+
+ 4px tab margin). The 24px button starts exactly at the title row's
|
|
628
|
+
bottom edge so its hover surface never touches "Session log" above. The
|
|
629
|
+
:has() guard keeps the in-flow position for the rare header without a
|
|
630
|
+
tablist, and z-index keeps them above the tabs' own stacking context. */
|
|
631
|
+
header:has([role="tablist"]) .gbar-hicons{
|
|
632
|
+
position:absolute;
|
|
633
|
+
top:calc(12px + 32px + 4px + (16px - 24px)/2);
|
|
634
|
+
right:28px;
|
|
635
|
+
z-index:2;
|
|
636
|
+
}
|
|
637
|
+
.gbar-hicon{position:relative;width:32px;height:24px;display:inline-flex;align-items:center;justify-content:center;
|
|
638
|
+
border:none;background:transparent;border-radius:8px;color:var(--dsw-alias-label-secondary);cursor:pointer;
|
|
639
|
+
transition:background .14s ease,color .14s ease}
|
|
640
|
+
.gbar-hicon:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
641
|
+
.gbar-hicon.gbar-on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);color:var(--dsw-alias-state-business-primary)}
|
|
642
|
+
.gbar-hicon svg{width:16px;height:16px}
|
|
643
|
+
/* uncommitted-changes dot on the diff icon — warn-primary, the same color
|
|
644
|
+
the branch chip / diff footer use for the dirty state */
|
|
645
|
+
.gbar-hicon-dot{position:absolute;top:2.5px;right:2.5px;width:6px;height:6px;border-radius:50%;
|
|
646
|
+
background:var(--dsw-alias-state-warn-primary);pointer-events:none}
|
|
647
|
+
|
|
648
|
+
/* 「打开项目」menu — a flat dropdown opening leftward from the icon group:
|
|
649
|
+
right edge sits 20px inside the window (the group anchors 28px from the
|
|
650
|
+
edge, so right:-8px), no bubble tail, compact rows, width fits content */
|
|
651
|
+
.gbar-openmenu{position:absolute;top:calc(100% + 6px);right:-8px;min-width:150px;max-width:260px;width:max-content;z-index:130;
|
|
652
|
+
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;
|
|
653
|
+
box-shadow:var(--dsw-shadow-lv2);padding:4px;text-align:left;animation:gpop-down .16s cubic-bezier(.32,.72,0,1)}
|
|
654
|
+
.gbar-omrow{display:flex;align-items:center;gap:9px;width:100%;padding:5px 8px;border:none;border-radius:7px;
|
|
655
|
+
background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;line-height:1.2;cursor:pointer;text-align:left;
|
|
656
|
+
white-space:nowrap}
|
|
657
|
+
.gbar-omrow:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
658
|
+
/* brand app icons (official full-color SVGs) — fixed slot, sized by CSS */
|
|
659
|
+
.gbar-omrow .gbar-aicon{width:15px;height:15px;flex:none;display:inline-flex}
|
|
660
|
+
.gbar-aicon svg{width:100%;height:100%;display:block}
|
|
661
|
+
|
|
662
|
+
/* terminal panel body — follows the app theme surface (tokens live on body,
|
|
663
|
+
and the panel portal renders inside body, so the var resolves); the xterm
|
|
664
|
+
viewport is transparent, so this is also the terminal's own backdrop */
|
|
665
|
+
.gbar-term{flex:1;min-height:0;background:var(--dsw-alias-bg-base,#16161b);color:var(--dsw-alias-label-primary,#d6d6dc);cursor:text;
|
|
666
|
+
font-family:var(--dsw-font-markdown-code-font-family,"SF Mono",Consolas,monospace);font-size:12px;line-height:1.75;
|
|
667
|
+
padding:10px 14px;overflow:auto;scrollbar-width:thin}
|
|
668
|
+
.gbar-term:focus-visible{outline:none}
|
|
669
|
+
.gbar-term .gbar-ps1{color:#7d94ff}
|
|
670
|
+
.gbar-term .gbar-tcmd{color:#ffffff}
|
|
671
|
+
.gbar-term .gbar-tout{white-space:pre-wrap;word-break:break-all;opacity:.88}
|
|
672
|
+
.gbar-term .gbar-tdim{opacity:.45}
|
|
673
|
+
.gbar-term .gbar-terr{color:#ef6b70}
|
|
674
|
+
.gbar-term .gbar-tin{display:flex;align-items:baseline}
|
|
675
|
+
.gbar-term .gbar-tin input{flex:1;min-width:0;background:transparent;border:none;outline:none;color:#ffffff;
|
|
676
|
+
font:inherit;font-size:12px;line-height:1.75;caret-color:#7d94ff;padding:0}
|
|
677
|
+
|
|
678
|
+
/* xterm host variant — the emulator fills the body and sizes itself via the
|
|
679
|
+
fit addon, so the container clips instead of scrolling. */
|
|
680
|
+
.gbar-xterm{padding:4px 6px;overflow:hidden;cursor:normal}
|
|
681
|
+
.gbar-xterm .xterm{height:100%}
|
|
682
|
+
.gbar-xterm .xterm .xterm-viewport{background:transparent !important}
|
|
683
|
+
|
|
684
|
+
/* fatal terminal error banner (pty unavailable / socket refused) */
|
|
685
|
+
.gbar-term-error{position:absolute;left:12px;right:12px;bottom:12px;z-index:6;display:flex;align-items:center;gap:10px;
|
|
686
|
+
padding:9px 12px;border-radius:10px;border:1px solid color-mix(in srgb,#ef6b70 45%,transparent);
|
|
687
|
+
background:var(--dsw-alias-bg-base,#16161b);box-shadow:0 8px 24px rgba(0,0,0,.25)}
|
|
688
|
+
.gbar-term-error-text{flex:1;min-width:0;font-size:12px;color:var(--dsw-alias-label-primary);
|
|
689
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
690
|
+
.gbar-term-error button{border:1px solid var(--dsw-alias-border-l1);background:transparent;border-radius:7px;
|
|
691
|
+
color:var(--dsw-alias-label-secondary);width:26px;height:26px;cursor:pointer;flex:none;font-size:13px}
|
|
692
|
+
.gbar-term-error button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
693
|
+
|
|
694
|
+
/* half-screen toggle button in side-panel heads */
|
|
695
|
+
.gbar-side-half{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-tertiary);
|
|
696
|
+
width:28px;height:28px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;
|
|
697
|
+
cursor:pointer;flex:none;transition:background .15s ease,color .15s ease,border-color .15s ease}
|
|
698
|
+
.gbar-side-half:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
699
|
+
.gbar-side-half.gbar-on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);
|
|
700
|
+
color:var(--dsw-alias-state-business-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 40%,transparent)}
|
|
701
|
+
.gbar-side-half svg{width:15px;height:15px;display:block}
|
|
702
|
+
|
|
703
|
+
/* hero (new-session) floating chip wrapper */
|
|
704
|
+
.gbar-hero{position:fixed;z-index:110;transform:translateY(-50%);display:inline-flex;align-items:center;gap:6px}
|
|
705
|
+
@media (prefers-reduced-motion:reduce){.gbar-side,.gbar-modal,.gbar-notice,.gbar-pop,.gbar-openmenu{animation:none}}
|
|
706
|
+
`
|
|
707
|
+
|
|
708
|
+
/** Install the GitBar stylesheet once (idempotent); returns the disposer. */
|
|
709
|
+
export function installGitBarStyles(): () => void {
|
|
710
|
+
const id = 'dsh-ui-tweaks-gitbar'
|
|
711
|
+
const existing = document.querySelector(`style[data-plugin-css="${id}"]`)
|
|
712
|
+
if (existing !== null) return () => {}
|
|
713
|
+
const style = document.createElement('style')
|
|
714
|
+
style.dataset.plugin = 'dsh-ui-tweaks'
|
|
715
|
+
style.dataset.pluginCss = id
|
|
716
|
+
style.textContent = GITBAR_CSS
|
|
717
|
+
document.head.appendChild(style)
|
|
718
|
+
return () => { style.remove() }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// Status / data plumbing.
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
/** Extract a short branch name for display (origin/main → main). */
|
|
726
|
+
function shortBranch(name: string): string {
|
|
727
|
+
const idx = name.lastIndexOf('/')
|
|
728
|
+
return idx >= 0 && name.startsWith('origin/') ? name.slice(idx + 1) : name
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Branch names that must never be deletable (main/master). */
|
|
732
|
+
function isProtectedBranch(name: string): boolean {
|
|
733
|
+
return name === 'main' || name === 'master'
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function statusLetter(status: string): string {
|
|
737
|
+
if (status === 'U') return 'U'
|
|
738
|
+
return status.length > 1 ? status[0] ?? 'M' : status
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function statusClass(status: string): string {
|
|
742
|
+
const s = statusLetter(status)
|
|
743
|
+
if (s === 'A') return 'gbar-a'
|
|
744
|
+
if (s === 'D') return 'gbar-d'
|
|
745
|
+
if (s === 'U') return 'gbar-u'
|
|
746
|
+
return 'gbar-m'
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
// Git status polling hook (shared by both pills and both panels).
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Module-level snapshot cache keyed by target. Every useGitStatus instance
|
|
755
|
+
* (header icons, diff panel, terminal panel) reads and writes through it, so
|
|
756
|
+
* a panel mounting on click starts from the warm snapshot the header poller
|
|
757
|
+
* already fetched instead of a blank state — this is what makes the diff
|
|
758
|
+
* panel paint instantly instead of waiting for its own /status round-trip.
|
|
759
|
+
*/
|
|
760
|
+
const snapshotCache = new Map<string, GitSnapshot>()
|
|
761
|
+
|
|
762
|
+
// A localStorage mirror of this cache existed briefly and was retired; sweep
|
|
763
|
+
// away any copy an older build left behind in this browser.
|
|
764
|
+
try { window.localStorage.removeItem('dsh-ui-tweaks.git.snapshots.v1') } catch { /* storage unavailable */ }
|
|
765
|
+
|
|
766
|
+
/** In-flight warmups keyed by target, so the dock warmup and a mounting chip
|
|
767
|
+
* never double-fetch the same target. */
|
|
768
|
+
const warmInflight = new Map<string, Promise<void>>()
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Fetch a target's status into the shared cache WITHOUT rendering anything.
|
|
772
|
+
*
|
|
773
|
+
* The session-header chips mount only after the conversation projection
|
|
774
|
+
* finishes loading — seconds on large inactive sessions — so their own fetch
|
|
775
|
+
* would start just as late. This warmup runs from the input dock, which mounts
|
|
776
|
+
* immediately on session switch: by the time the header chip appears it reads
|
|
777
|
+
* a warm cache and paints folder+branch instantly.
|
|
778
|
+
*/
|
|
779
|
+
export function warmGitStatus(target: GitTarget): void {
|
|
780
|
+
const key = target.session ?? target.ws ?? ''
|
|
781
|
+
if (key === '' || snapshotCache.has(key) || warmInflight.has(key)) return
|
|
782
|
+
const flight = apiGet<GitSnapshot>(`${GIT_ROUTE}/status?${targetQuery(target)}`)
|
|
783
|
+
.then(next => {
|
|
784
|
+
// An unresolved answer (host still starting up) must not poison the
|
|
785
|
+
// cache as a final "not a repo"; the mounting chip re-fetches anyway.
|
|
786
|
+
if (!next.unresolved) snapshotCache.set(key, next)
|
|
787
|
+
})
|
|
788
|
+
.catch(() => {
|
|
789
|
+
// Warmup is best-effort; the mounting chip re-fetches anyway.
|
|
790
|
+
})
|
|
791
|
+
.finally(() => { warmInflight.delete(key) })
|
|
792
|
+
warmInflight.set(key, flight)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Input-dock seat for {@link warmGitStatus}: mounts immediately on session
|
|
796
|
+
* switch (long before the session header does) and kicks off the prefetch. */
|
|
797
|
+
export function GitWarmup({ sessionId }: { sessionId: SessionId }) {
|
|
798
|
+
useEffect(() => {
|
|
799
|
+
warmGitStatus({ session: String(sessionId) })
|
|
800
|
+
}, [sessionId])
|
|
801
|
+
return null
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Fast retry steps (ms) for the startup race: right after a restart the host
|
|
805
|
+
* has not materialized its session/workspace store yet, so the first /status
|
|
806
|
+
* answers `unresolved`. Without these the UI sat blank for a full POLL_MS. */
|
|
807
|
+
const RETRY_DELAYS_MS = [400, 800, 1600, 3200] as const
|
|
808
|
+
/** Upper bound on fast retries before falling back to the steady poll. */
|
|
809
|
+
const FAST_RETRY_MAX = 6
|
|
810
|
+
|
|
811
|
+
/** Load the git snapshot on session change, then poll; returns [snapshot, refresh]. */
|
|
812
|
+
function useGitStatus(enabled: boolean, target: GitTarget): [GitSnapshot | null, () => Promise<void>] {
|
|
813
|
+
const key = target.session ?? target.ws ?? ''
|
|
814
|
+
// Lazy initializer: a freshly mounted panel is warm on its very first render.
|
|
815
|
+
const [snapshot, setSnapshot] = useState<GitSnapshot | null>(() => snapshotCache.get(key) ?? null)
|
|
816
|
+
|
|
817
|
+
useEffect(() => {
|
|
818
|
+
if (!enabled || key === '') {
|
|
819
|
+
setSnapshot(null)
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
// A remount (panel open) must not regress to blank when the cache is warm.
|
|
823
|
+
setSnapshot(snapshotCache.get(key) ?? null)
|
|
824
|
+
let cancelled = false
|
|
825
|
+
let pollTimer: number | undefined
|
|
826
|
+
let retryTimer: number | undefined
|
|
827
|
+
let attempt = 0
|
|
828
|
+
// Whether a resolved snapshot exists for this target (cache or fetched).
|
|
829
|
+
// Drives the cold-start chase: fast backoff only while it is false.
|
|
830
|
+
let haveGood = snapshotCache.has(key)
|
|
831
|
+
|
|
832
|
+
/** Store one fetch result. An `unresolved` answer is transient — never
|
|
833
|
+
* cached, never regressing an already-good snapshot. Returns whether a
|
|
834
|
+
* resolved answer landed. */
|
|
835
|
+
const apply = (next: GitSnapshot): boolean => {
|
|
836
|
+
if (next.unresolved === true) {
|
|
837
|
+
if (!cancelled && !haveGood) setSnapshot(null)
|
|
838
|
+
return false
|
|
839
|
+
}
|
|
840
|
+
haveGood = true
|
|
841
|
+
attempt = 0
|
|
842
|
+
snapshotCache.set(key, next)
|
|
843
|
+
if (!cancelled) setSnapshot(next)
|
|
844
|
+
return true
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const refreshOnce = async (): Promise<boolean> => {
|
|
848
|
+
try {
|
|
849
|
+
return apply(await apiGet<GitSnapshot>(`${GIT_ROUTE}/status?${targetQuery(target)}`))
|
|
850
|
+
} catch {
|
|
851
|
+
// Transport/transient git failure: chase it too while no good snapshot
|
|
852
|
+
// exists; afterwards keep showing the old one until the next poll.
|
|
853
|
+
return haveGood
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Cold-start chase: short backoff instead of idling a whole poll period. */
|
|
858
|
+
const scheduleRetry = (): void => {
|
|
859
|
+
if (cancelled || haveGood || attempt >= FAST_RETRY_MAX) return
|
|
860
|
+
const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ?? 3200
|
|
861
|
+
attempt += 1
|
|
862
|
+
retryTimer = window.setTimeout(() => {
|
|
863
|
+
if (cancelled) return
|
|
864
|
+
void refreshOnce().then(settled => { if (!settled) scheduleRetry() })
|
|
865
|
+
}, delay)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
void refreshOnce().then(settled => { if (!settled) scheduleRetry() })
|
|
869
|
+
|
|
870
|
+
pollTimer = window.setInterval(() => {
|
|
871
|
+
if (document.visibilityState === 'visible') void refreshOnce()
|
|
872
|
+
}, POLL_MS)
|
|
873
|
+
return () => {
|
|
874
|
+
cancelled = true
|
|
875
|
+
if (pollTimer !== undefined) clearInterval(pollTimer)
|
|
876
|
+
if (retryTimer !== undefined) clearTimeout(retryTimer)
|
|
877
|
+
}
|
|
878
|
+
}, [enabled, key])
|
|
879
|
+
|
|
880
|
+
const refresh = useCallback(async (): Promise<void> => {
|
|
881
|
+
if (key === '') return
|
|
882
|
+
try {
|
|
883
|
+
const next = await apiGet<GitSnapshot>(`${GIT_ROUTE}/status?${targetQuery(target)}`)
|
|
884
|
+
// Same rule as the hook body: an unresolved answer is transient and must
|
|
885
|
+
// not evict a good snapshot or enter the cache.
|
|
886
|
+
if (!next.unresolved) {
|
|
887
|
+
snapshotCache.set(key, next)
|
|
888
|
+
setSnapshot(next)
|
|
889
|
+
}
|
|
890
|
+
} catch {
|
|
891
|
+
// Keep the previous snapshot on transient failures.
|
|
892
|
+
}
|
|
893
|
+
}, [target.session, target.ws])
|
|
894
|
+
|
|
895
|
+
return [snapshot, refresh]
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
// Branch chip — `conversation.session.header.actions` (beside the title,
|
|
900
|
+
// before the mode badge). Native header-seat styling; the menu opens DOWNWARD
|
|
901
|
+
// from the chip. The folder label comes from the snapshot's cwd.
|
|
902
|
+
// ---------------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
export interface BranchChipProps {
|
|
905
|
+
/** Framework session kit: the definite current session id. */
|
|
906
|
+
sessionId: SessionId
|
|
907
|
+
/** Injected: the client sessions service (list feed carrying each row's cwd). */
|
|
908
|
+
sessionsService: ISessions
|
|
909
|
+
/** Injected: the ui-tweaks settings store (reads `gitBarEnabled`). */
|
|
910
|
+
controller: SettingsClient
|
|
911
|
+
/** Locale-bound translator for the GitBar labels. */
|
|
912
|
+
t: Translate
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export function BranchChipEntry({ sessionId, sessionsService, controller, t }: BranchChipProps) {
|
|
916
|
+
const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
|
|
917
|
+
const enabled = settingsState.value?.gitBarEnabled ?? true
|
|
918
|
+
const sessionStr = String(sessionId)
|
|
919
|
+
const target: GitTarget = { session: sessionStr }
|
|
920
|
+
|
|
921
|
+
// Folder straight off the live sessions list feed (the same rows the left
|
|
922
|
+
// sidebar shows) — zero network, correct from the very first paint even
|
|
923
|
+
// before /status answers.
|
|
924
|
+
const list = sessionsService.list
|
|
925
|
+
const subscribeList = useMemo(() => list.subscribe.bind(list), [list])
|
|
926
|
+
const feedCwd = useSyncExternalStore(
|
|
927
|
+
subscribeList,
|
|
928
|
+
() => list.getSnapshot().byId[sessionId]?.cwd,
|
|
929
|
+
() => undefined,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
const [snapshot, refresh] = useGitStatus(enabled, target)
|
|
933
|
+
const [branchOpen, setBranchOpen] = useState(false)
|
|
934
|
+
const [branches, setBranches] = useState<GitBranches | null>(null)
|
|
935
|
+
const [newBranchName, setNewBranchName] = useState('')
|
|
936
|
+
const [pushToRemote, setPushToRemote] = useState(false)
|
|
937
|
+
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
|
938
|
+
const [baseBranch, setBaseBranch] = useState('')
|
|
939
|
+
const [newBranchOpen, setNewBranchOpen] = useState(false)
|
|
940
|
+
const [graphOpen, setGraphOpen] = useState(false)
|
|
941
|
+
const [graph, setGraph] = useState<GitGraph | null>(null)
|
|
942
|
+
const [graphBusy, setGraphBusy] = useState(false)
|
|
943
|
+
const [busy, setBusy] = useState<string | null>(null)
|
|
944
|
+
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
|
945
|
+
const branchRef = useRef<HTMLButtonElement | null>(null)
|
|
946
|
+
const branchPopRef = useRef<HTMLDivElement | null>(null)
|
|
947
|
+
|
|
948
|
+
// Drop any open popup/dialog when the session or the toggle changes.
|
|
949
|
+
useEffect(() => {
|
|
950
|
+
if (!enabled || sessionStr === undefined) {
|
|
951
|
+
setBranchOpen(false)
|
|
952
|
+
setNewBranchOpen(false)
|
|
953
|
+
setGraphOpen(false)
|
|
954
|
+
}
|
|
955
|
+
}, [enabled, sessionStr])
|
|
956
|
+
|
|
957
|
+
const showNotice = (kind: 'ok' | 'err', text: string): void => {
|
|
958
|
+
setNotice({ kind, text })
|
|
959
|
+
window.setTimeout(() => setNotice(null), 4000)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const run = async (label: string, fn: () => Promise<void>): Promise<boolean> => {
|
|
963
|
+
if (busy !== null) return false
|
|
964
|
+
setBusy(label)
|
|
965
|
+
try {
|
|
966
|
+
await fn()
|
|
967
|
+
return true
|
|
968
|
+
} catch (cause) {
|
|
969
|
+
showNotice('err', cause instanceof Error ? cause.message : String(cause))
|
|
970
|
+
return false
|
|
971
|
+
} finally {
|
|
972
|
+
setBusy(null)
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const loadBranches = (): void => {
|
|
977
|
+
if (sessionStr === undefined) return
|
|
978
|
+
void apiGet<GitBranches>(`${GIT_ROUTE}/branches?${targetQuery(target)}`)
|
|
979
|
+
.then(setBranches)
|
|
980
|
+
.catch(cause => { showNotice('err', cause instanceof Error ? cause.message : String(cause)) })
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const toggleBranch = (): void => {
|
|
984
|
+
const next = !branchOpen
|
|
985
|
+
setBranchOpen(next)
|
|
986
|
+
if (next) loadBranches()
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const pickBranch = (name: string): void => {
|
|
990
|
+
if (sessionStr === undefined || name === branches?.current) return
|
|
991
|
+
void run('checkout', async () => {
|
|
992
|
+
await apiPost(`${GIT_ROUTE}/checkout`, { session: sessionStr, branch: name })
|
|
993
|
+
setBranchOpen(false)
|
|
994
|
+
await refresh()
|
|
995
|
+
showNotice('ok', `→ ${name}`)
|
|
996
|
+
})
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const createBranch = (): void => {
|
|
1000
|
+
if (sessionStr === undefined || newBranchName.trim() === '') return
|
|
1001
|
+
void run(pushToRemote ? 'create-push' : 'create', async () => {
|
|
1002
|
+
await apiPost(`${GIT_ROUTE}/create`, {
|
|
1003
|
+
session: sessionStr,
|
|
1004
|
+
name: newBranchName.trim(),
|
|
1005
|
+
base: baseBranch === '' ? undefined : baseBranch,
|
|
1006
|
+
push: pushToRemote,
|
|
1007
|
+
})
|
|
1008
|
+
setBranchOpen(false)
|
|
1009
|
+
setNewBranchOpen(false)
|
|
1010
|
+
setNewBranchName('')
|
|
1011
|
+
setPushToRemote(false)
|
|
1012
|
+
setBaseBranch('')
|
|
1013
|
+
await refresh()
|
|
1014
|
+
if (pushToRemote) showNotice('ok', `☁ ${newBranchName.trim()}`)
|
|
1015
|
+
})
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const openNewBranch = (): void => {
|
|
1019
|
+
setNewBranchName('')
|
|
1020
|
+
setBaseBranch('')
|
|
1021
|
+
setPushToRemote(false)
|
|
1022
|
+
setNewBranchOpen(true)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Two-step branch deletion: first click arms a confirm, second click deletes.
|
|
1026
|
+
const deleteBranch = (name: string): void => {
|
|
1027
|
+
if (sessionStr === undefined) return
|
|
1028
|
+
if (confirmDelete === name) {
|
|
1029
|
+
setConfirmDelete(null)
|
|
1030
|
+
void run('branch-delete', async () => {
|
|
1031
|
+
await apiPost(`${GIT_ROUTE}/branch-delete`, { session: sessionStr, name })
|
|
1032
|
+
loadBranches()
|
|
1033
|
+
await refresh()
|
|
1034
|
+
showNotice('ok', `🗑 ${name}`)
|
|
1035
|
+
})
|
|
1036
|
+
} else {
|
|
1037
|
+
setConfirmDelete(name)
|
|
1038
|
+
window.setTimeout(() => {
|
|
1039
|
+
setConfirmDelete(current => (current === name ? null : current))
|
|
1040
|
+
}, 3000)
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Two-step remote-branch deletion (`git push origin --delete <branch>`).
|
|
1045
|
+
const deleteRemoteBranch = (name: string): void => {
|
|
1046
|
+
if (sessionStr === undefined) return
|
|
1047
|
+
if (confirmDelete === name) {
|
|
1048
|
+
setConfirmDelete(null)
|
|
1049
|
+
void run('remote-delete', async () => {
|
|
1050
|
+
await apiPost(`${GIT_ROUTE}/remote-delete`, { session: sessionStr, name })
|
|
1051
|
+
loadBranches()
|
|
1052
|
+
await refresh()
|
|
1053
|
+
showNotice('ok', `🗑 ${name}`)
|
|
1054
|
+
})
|
|
1055
|
+
} else {
|
|
1056
|
+
setConfirmDelete(name)
|
|
1057
|
+
window.setTimeout(() => {
|
|
1058
|
+
setConfirmDelete(current => (current === name ? null : current))
|
|
1059
|
+
}, 3000)
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// --- git commit graph dialog ------------------------------------------------
|
|
1064
|
+
const fetchGraph = (): void => {
|
|
1065
|
+
if (sessionStr === undefined) return
|
|
1066
|
+
setGraphBusy(true)
|
|
1067
|
+
void apiGet<GitGraph>(`${GIT_ROUTE}/graph?session=${encodeURIComponent(sessionStr)}&limit=150`)
|
|
1068
|
+
.then(setGraph)
|
|
1069
|
+
.catch(cause => { showNotice('err', cause instanceof Error ? cause.message : String(cause)) })
|
|
1070
|
+
.finally(() => { setGraphBusy(false) })
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const openGraph = (): void => {
|
|
1074
|
+
setGraphOpen(true)
|
|
1075
|
+
fetchGraph()
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Close the branch popup when clicking outside it or pressing Escape.
|
|
1079
|
+
useEffect(() => {
|
|
1080
|
+
if (!branchOpen) return
|
|
1081
|
+
const onDown = (event: MouseEvent): void => {
|
|
1082
|
+
const target = event.target as Node | null
|
|
1083
|
+
if (target === null) return
|
|
1084
|
+
if (branchRef.current?.contains(target) || branchPopRef.current?.contains(target)) return
|
|
1085
|
+
setBranchOpen(false)
|
|
1086
|
+
}
|
|
1087
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
1088
|
+
if (event.key === 'Escape') setBranchOpen(false)
|
|
1089
|
+
}
|
|
1090
|
+
document.addEventListener('mousedown', onDown)
|
|
1091
|
+
document.addEventListener('keydown', onKey)
|
|
1092
|
+
return () => {
|
|
1093
|
+
document.removeEventListener('mousedown', onDown)
|
|
1094
|
+
document.removeEventListener('keydown', onKey)
|
|
1095
|
+
}
|
|
1096
|
+
}, [branchOpen])
|
|
1097
|
+
|
|
1098
|
+
if (!enabled || sessionStr === undefined) return null
|
|
1099
|
+
// A genuine non-repo directory hides the chip entirely. An UNRESOLVED target
|
|
1100
|
+
// (host still materializing sessions right after a restart) is transient:
|
|
1101
|
+
// fall through to the folder-only paint below instead of going blank.
|
|
1102
|
+
if (snapshot !== null && snapshot.unresolved !== true && !snapshot.isRepo) return null
|
|
1103
|
+
|
|
1104
|
+
// While the first /status round-trip is in flight — or while the host has
|
|
1105
|
+
// not resolved this session yet — paint the folder alone straight off the
|
|
1106
|
+
// sessions-list feed (the sidebar's own rows — zero network, so it appears
|
|
1107
|
+
// the moment the sidebar row does); the branch capsule joins as soon as the
|
|
1108
|
+
// backoff chase lands a real snapshot.
|
|
1109
|
+
if (snapshot === null || snapshot.unresolved === true) {
|
|
1110
|
+
if (feedCwd === undefined || feedCwd === '') return null
|
|
1111
|
+
return (
|
|
1112
|
+
<span className="gbar" data-slot-plugin="dsh-ui-tweaks-gitbar">
|
|
1113
|
+
<span className="gbar-fchip" title={feedCwd}>
|
|
1114
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1115
|
+
<path d="M1.5 4A1.5 1.5 0 0 1 3 2.5h3l1.5 2H13A1.5 1.5 0 0 1 14.5 6v6A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4Z" />
|
|
1116
|
+
</svg>
|
|
1117
|
+
<span className="gbar-folder">{basenameOf(feedCwd)}</span>
|
|
1118
|
+
</span>
|
|
1119
|
+
</span>
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const dirty = !snapshot.clean
|
|
1124
|
+
|
|
1125
|
+
return (
|
|
1126
|
+
<span className="gbar" data-slot-plugin="dsh-ui-tweaks-gitbar">
|
|
1127
|
+
{/* Folder label — plain (no capsule), not clickable */}
|
|
1128
|
+
<span className="gbar-fchip" title={feedCwd ?? snapshot.cwd}>
|
|
1129
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1130
|
+
<path d="M1.5 4A1.5 1.5 0 0 1 3 2.5h3l1.5 2H13A1.5 1.5 0 0 1 14.5 6v6A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4Z" />
|
|
1131
|
+
</svg>
|
|
1132
|
+
<span className="gbar-folder">{basenameOf(feedCwd ?? snapshot.cwd)}</span>
|
|
1133
|
+
</span>
|
|
1134
|
+
{/* Branch capsule — clickable, opens the branch popup */}
|
|
1135
|
+
<span className="gbar-bwrap">
|
|
1136
|
+
<button
|
|
1137
|
+
type="button"
|
|
1138
|
+
ref={branchRef}
|
|
1139
|
+
className={'gbar-chip' + (branchOpen ? ' gbar-open' : '')}
|
|
1140
|
+
onClick={toggleBranch}
|
|
1141
|
+
title={dirty ? t('dirty') : t('clean')}
|
|
1142
|
+
aria-expanded={branchOpen}
|
|
1143
|
+
>
|
|
1144
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1145
|
+
<circle cx="4.5" cy="3.5" r="1.8" />
|
|
1146
|
+
<circle cx="4.5" cy="12.5" r="1.8" />
|
|
1147
|
+
<circle cx="12" cy="5.5" r="1.8" />
|
|
1148
|
+
<path d="M4.5 5.3v5.4" />
|
|
1149
|
+
<path d="M12 7.3c0 3-3.5 4.8-7.5 3.4" />
|
|
1150
|
+
</svg>
|
|
1151
|
+
<span className="gbar-bname">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
|
|
1152
|
+
</button>
|
|
1153
|
+
|
|
1154
|
+
{/* Branch popup — pinned header (current branch + worktree state),
|
|
1155
|
+
scrolling local/remote lists, pinned action entries */}
|
|
1156
|
+
{branchOpen ? (
|
|
1157
|
+
<div className="gbar-pop" role="menu" aria-label={snapshot.branch ?? 'git'} ref={branchPopRef}>
|
|
1158
|
+
<div className="gbar-pop-head">
|
|
1159
|
+
<span className="gbar-curname" title={dirty ? t('dirty') : t('clean')}>
|
|
1160
|
+
<svg className="gbar-bicon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1161
|
+
<circle cx="4.5" cy="3.5" r="1.8" />
|
|
1162
|
+
<circle cx="4.5" cy="12.5" r="1.8" />
|
|
1163
|
+
<circle cx="12" cy="5.5" r="1.8" />
|
|
1164
|
+
<path d="M4.5 5.3v5.4" />
|
|
1165
|
+
<path d="M12 7.3c0 3-3.5 4.8-7.5 3.4" />
|
|
1166
|
+
</svg>
|
|
1167
|
+
<span>{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
|
|
1168
|
+
</span>
|
|
1169
|
+
<span className="gbar-state">
|
|
1170
|
+
<span className={'gbar-dot ' + (dirty ? 'gbar-dirty' : 'gbar-clean')} aria-hidden />
|
|
1171
|
+
{snapshot.ahead > 0 ? <span>↑{snapshot.ahead}</span> : null}
|
|
1172
|
+
{snapshot.behind > 0 ? <span>↓{snapshot.behind}</span> : null}
|
|
1173
|
+
</span>
|
|
1174
|
+
</div>
|
|
1175
|
+
<div className="gbar-pop-body">
|
|
1176
|
+
<div className="gbar-sec">{t('branchLocal')}{branches !== null ? <span className="gbar-count">{branches.local.length}</span> : null}</div>
|
|
1177
|
+
{branches === null ? (
|
|
1178
|
+
<div className="gbar-loading"><span className="gbar-spin" />{t('loading')}</div>
|
|
1179
|
+
) : branches.local.map(name => (
|
|
1180
|
+
<div key={name} className="gbar-rowwrap">
|
|
1181
|
+
<button
|
|
1182
|
+
type="button"
|
|
1183
|
+
className={'gbar-row' + (name === branches.current ? ' gbar-cur' : '')}
|
|
1184
|
+
onClick={() => { pickBranch(name) }}
|
|
1185
|
+
>
|
|
1186
|
+
<svg className="gbar-bicon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1187
|
+
<circle cx="4.5" cy="3.5" r="1.8" />
|
|
1188
|
+
<circle cx="4.5" cy="12.5" r="1.8" />
|
|
1189
|
+
<path d="M4.5 5.3v5.4" />
|
|
1190
|
+
<path d="M11.5 3.7v3a2.6 2.6 0 0 1-2.6 2.6H4.5" />
|
|
1191
|
+
</svg>
|
|
1192
|
+
<span>{name}</span>
|
|
1193
|
+
{name === branches.current ? <span className="gbar-check" aria-hidden>✓</span> : null}
|
|
1194
|
+
</button>
|
|
1195
|
+
{name !== branches.current && !isProtectedBranch(name) ? (
|
|
1196
|
+
<button
|
|
1197
|
+
type="button"
|
|
1198
|
+
className={'gbar-del' + (confirmDelete === name ? ' gbar-arm' : '')}
|
|
1199
|
+
onClick={() => { deleteBranch(name) }}
|
|
1200
|
+
title={t('branchDelete')}
|
|
1201
|
+
>
|
|
1202
|
+
{confirmDelete === name ? t('branchDeleteConfirm') : (
|
|
1203
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1204
|
+
<path d="M2.5 4.5h11" />
|
|
1205
|
+
<path d="M5.5 4.5V3.3c0-.44.36-.8.8-.8h3.4c.44 0 .8.36.8.8v1.2" />
|
|
1206
|
+
<path d="M4 4.5l.6 8.2c.04.5.45.8.95.8h4.9c.5 0 .91-.3.95-.8l.6-8.2" />
|
|
1207
|
+
</svg>
|
|
1208
|
+
)}
|
|
1209
|
+
</button>
|
|
1210
|
+
) : null}
|
|
1211
|
+
</div>
|
|
1212
|
+
))}
|
|
1213
|
+
<div className="gbar-sec">{t('branchRemote')}{branches !== null ? <span className="gbar-count">{branches.remote.length}</span> : null}</div>
|
|
1214
|
+
{branches?.remote.map(name => (
|
|
1215
|
+
<div key={name} className="gbar-rowwrap">
|
|
1216
|
+
<button
|
|
1217
|
+
type="button"
|
|
1218
|
+
className={'gbar-row' + (name === branches.current ? ' gbar-cur' : '')}
|
|
1219
|
+
onClick={() => { pickBranch(name) }}
|
|
1220
|
+
>
|
|
1221
|
+
<svg className="gbar-bicon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1222
|
+
<path d="M5.2 13a3.2 3.2 0 1 1 .5-6.36 4 4 0 0 1 7.75 1.06 2.65 2.65 0 0 1-.55 5.3H5.2Z" />
|
|
1223
|
+
</svg>
|
|
1224
|
+
<span>{shortBranch(name)}</span>
|
|
1225
|
+
<span className="gbar-rm">{name.split('/')[0]}</span>
|
|
1226
|
+
</button>
|
|
1227
|
+
{!isProtectedBranch(shortBranch(name)) ? (
|
|
1228
|
+
<button
|
|
1229
|
+
type="button"
|
|
1230
|
+
className={'gbar-del' + (confirmDelete === name ? ' gbar-arm' : '')}
|
|
1231
|
+
onClick={() => { deleteRemoteBranch(name) }}
|
|
1232
|
+
title={t('branchRemoteDelete')}
|
|
1233
|
+
>
|
|
1234
|
+
{confirmDelete === name ? t('branchDeleteConfirm') : (
|
|
1235
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1236
|
+
<path d="M2.5 4.5h11" />
|
|
1237
|
+
<path d="M5.5 4.5V3.3c0-.44.36-.8.8-.8h3.4c.44 0 .8.36.8.8v1.2" />
|
|
1238
|
+
<path d="M4 4.5l.6 8.2c.04.5.45.8.95.8h4.9c.5 0 .91-.3.95-.8l.6-8.2" />
|
|
1239
|
+
</svg>
|
|
1240
|
+
)}
|
|
1241
|
+
</button>
|
|
1242
|
+
) : null}
|
|
1243
|
+
</div>
|
|
1244
|
+
))}
|
|
1245
|
+
</div>
|
|
1246
|
+
<div className="gbar-actions">
|
|
1247
|
+
<button type="button" className="gbar-act" onClick={openNewBranch}>
|
|
1248
|
+
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden>
|
|
1249
|
+
<path d="M7.4 2.6a1.2 1.2 0 1 1 2.2 0l3.2 1.6a1.2 1.2 0 1 1-1.1 2.1l-2.3-1.2a2.6 2.6 0 0 1-1.1.8l.6 4.2a1.2 1.2 0 1 1-1.7.2l-.6-4.2a2.6 2.6 0 0 1-1.2-.7l-2.9 1.5a1.2 1.2 0 1 1-1.1-2.1l2.9-1.5a2.6 2.6 0 0 1 .2-1.5l-3.2-1.6a1.2 1.2 0 1 1 1.1-2.1z" />
|
|
1250
|
+
</svg>
|
|
1251
|
+
{t('branchNew')}
|
|
1252
|
+
</button>
|
|
1253
|
+
<button type="button" className="gbar-act" onClick={openGraph}>
|
|
1254
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
|
|
1255
|
+
<path d="M3 1.5v3.4a4.4 4.4 0 0 0 4.4 4.4h5.1" />
|
|
1256
|
+
<path d="M13 1.5v4.9a3.2 3.2 0 0 1-3.2 3.2H3" />
|
|
1257
|
+
<circle cx="3" cy="1.5" r="1.2" fill="currentColor" stroke="none" />
|
|
1258
|
+
<circle cx="13" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
|
1259
|
+
</svg>
|
|
1260
|
+
{t('branchGraph')}
|
|
1261
|
+
</button>
|
|
1262
|
+
</div>
|
|
1263
|
+
</div>
|
|
1264
|
+
) : null}
|
|
1265
|
+
</span>
|
|
1266
|
+
|
|
1267
|
+
{/* New-branch dialog */}
|
|
1268
|
+
{newBranchOpen ? createPortal(
|
|
1269
|
+
<div className="gbar-modal-wrap" onMouseDown={event => { if (event.target === event.currentTarget) setNewBranchOpen(false) }}>
|
|
1270
|
+
<div className="gbar-modal" role="dialog" aria-label={t('branchNew')}>
|
|
1271
|
+
<div className="gbar-head">
|
|
1272
|
+
<span className="gbar-title">{t('branchNew')}</span>
|
|
1273
|
+
<span className="gbar-branch">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
|
|
1274
|
+
<button type="button" className="gbar-x" onClick={() => { setNewBranchOpen(false) }} aria-label="✕">✕</button>
|
|
1275
|
+
</div>
|
|
1276
|
+
<input
|
|
1277
|
+
value={newBranchName}
|
|
1278
|
+
onChange={event => { setNewBranchName(event.target.value) }}
|
|
1279
|
+
onKeyDown={event => { if (event.key === 'Enter') createBranch() }}
|
|
1280
|
+
placeholder={t('branchNewPlaceholder')}
|
|
1281
|
+
aria-label={t('branchNew')}
|
|
1282
|
+
autoFocus
|
|
1283
|
+
/>
|
|
1284
|
+
<div className="gbar-baserow">
|
|
1285
|
+
<label className="gbar-baselabel" htmlFor="gbar-base-dlg">{t('branchFrom')}</label>
|
|
1286
|
+
<select
|
|
1287
|
+
id="gbar-base-dlg"
|
|
1288
|
+
className="gbar-base"
|
|
1289
|
+
value={baseBranch}
|
|
1290
|
+
onChange={event => { setBaseBranch(event.target.value) }}
|
|
1291
|
+
aria-label={t('branchFrom')}
|
|
1292
|
+
>
|
|
1293
|
+
<option value="">{t('branchFromHead')}</option>
|
|
1294
|
+
{branches?.local.map(name => <option key={name} value={name}>{name}</option>)}
|
|
1295
|
+
{branches?.remote.map(name => <option key={name} value={name}>{name}</option>)}
|
|
1296
|
+
</select>
|
|
1297
|
+
</div>
|
|
1298
|
+
<div className="gbar-pushrow">
|
|
1299
|
+
<button
|
|
1300
|
+
type="button"
|
|
1301
|
+
className={'gbar-chk' + (pushToRemote ? '' : ' gbar-off')}
|
|
1302
|
+
onClick={() => { setPushToRemote(value => !value) }}
|
|
1303
|
+
aria-label={t('branchPushRemote')}
|
|
1304
|
+
>
|
|
1305
|
+
{pushToRemote ? '✓' : '✕'}
|
|
1306
|
+
</button>
|
|
1307
|
+
<span className="gbar-pushlabel">{t('branchPushRemote')}</span>
|
|
1308
|
+
</div>
|
|
1309
|
+
<div className="gbar-foot">
|
|
1310
|
+
<button type="button" className="gbar-btn gbar-ghost" onClick={() => { setNewBranchOpen(false) }}>{t('branchCancel')}</button>
|
|
1311
|
+
<button type="button" className="gbar-btn gbar-primary" onClick={createBranch} disabled={busy !== null || newBranchName.trim() === ''}>
|
|
1312
|
+
{busy === 'create' || busy === 'create-push' ? <span className="gbar-spin" /> : null} {t('branchCreate')}
|
|
1313
|
+
</button>
|
|
1314
|
+
</div>
|
|
1315
|
+
</div>
|
|
1316
|
+
</div>,
|
|
1317
|
+
document.body,
|
|
1318
|
+
) : null}
|
|
1319
|
+
|
|
1320
|
+
{/* Commit-graph dialog */}
|
|
1321
|
+
{graphOpen ? createPortal(
|
|
1322
|
+
<div className="gbar-modal-wrap" onMouseDown={event => { if (event.target === event.currentTarget) setGraphOpen(false) }}>
|
|
1323
|
+
<div className="gbar-modal gbar-graph-modal" role="dialog" aria-label={t('graphTitle')}>
|
|
1324
|
+
<div className="gbar-head">
|
|
1325
|
+
<span className="gbar-title">{t('graphTitle')}</span>
|
|
1326
|
+
<span className="gbar-branch">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
|
|
1327
|
+
<span className="gbar-spacer" />
|
|
1328
|
+
<button type="button" className="gbar-x gbar-refresh" onClick={fetchGraph} aria-label={t('branchRefresh')} title={t('branchRefresh')} disabled={graphBusy}>
|
|
1329
|
+
{graphBusy ? <span className="gbar-spin" /> : (
|
|
1330
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
1331
|
+
<path d="M13.4 8a5.4 5.4 0 1 1-1.6-3.8" />
|
|
1332
|
+
<path d="M13.4 1.5v3h-3" />
|
|
1333
|
+
</svg>
|
|
1334
|
+
)}
|
|
1335
|
+
</button>
|
|
1336
|
+
<button type="button" className="gbar-x" onClick={() => { setGraphOpen(false) }} aria-label="✕">✕</button>
|
|
1337
|
+
</div>
|
|
1338
|
+
{graph === null ? (
|
|
1339
|
+
<div className="gbar-graph-empty">{graphBusy ? t('loading') : t('noChanges')}</div>
|
|
1340
|
+
) : graph.commits.length === 0 ? (
|
|
1341
|
+
<div className="gbar-graph-empty">{t('noChanges')}</div>
|
|
1342
|
+
) : (
|
|
1343
|
+
<div className="gbar-graph-table" role="table" aria-label={t('graphTitle')}>
|
|
1344
|
+
<div className="gbar-graph-row gbar-graph-head" role="row">
|
|
1345
|
+
<span className="gbar-c-hash" role="columnheader">{t('graphColCommit')}</span>
|
|
1346
|
+
<span className="gbar-c-subject" role="columnheader">{t('graphColSubject')}</span>
|
|
1347
|
+
<span className="gbar-c-author" role="columnheader">{t('graphColAuthor')}</span>
|
|
1348
|
+
<span className="gbar-c-date" role="columnheader">{t('graphColDate')}</span>
|
|
1349
|
+
</div>
|
|
1350
|
+
{graph.commits.map(commit => (
|
|
1351
|
+
<div key={commit.fullHash} className="gbar-graph-row" role="row">
|
|
1352
|
+
<span className="gbar-c-hash" role="cell"><code className="gbar-hash">{commit.hash}</code></span>
|
|
1353
|
+
<span className="gbar-c-subject" role="cell">
|
|
1354
|
+
<span className="gbar-subject">{commit.subject}</span>
|
|
1355
|
+
{commit.refs !== '' ? <span className="gbar-refs">{commit.refs}</span> : null}
|
|
1356
|
+
</span>
|
|
1357
|
+
<span className="gbar-c-author" role="cell">{commit.author}</span>
|
|
1358
|
+
<span className="gbar-c-date" role="cell" title={commit.date}>{commit.dateRelative}</span>
|
|
1359
|
+
</div>
|
|
1360
|
+
))}
|
|
1361
|
+
</div>
|
|
1362
|
+
)}
|
|
1363
|
+
</div>
|
|
1364
|
+
</div>,
|
|
1365
|
+
document.body,
|
|
1366
|
+
) : null}
|
|
1367
|
+
|
|
1368
|
+
{/* Transient notice */}
|
|
1369
|
+
{notice !== null ? createPortal(
|
|
1370
|
+
<div className={'gbar-notice gbar-' + notice.kind} role="status">{notice.text}</div>,
|
|
1371
|
+
document.body,
|
|
1372
|
+
) : null}
|
|
1373
|
+
</span>
|
|
1374
|
+
)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// ---------------------------------------------------------------------------
|
|
1378
|
+
// Diff panel — opened from the header utilities' diff icon (always available,
|
|
1379
|
+
// even on a clean tree). Slide-over on the right; commit band at its foot.
|
|
1380
|
+
// ---------------------------------------------------------------------------
|
|
1381
|
+
|
|
1382
|
+
export interface DiffPanelProps {
|
|
1383
|
+
/** Framework session kit: the definite current session id. */
|
|
1384
|
+
sessionId: SessionId
|
|
1385
|
+
/** Framework session kit: live conversation snapshot selector. */
|
|
1386
|
+
useSession: <T>(selector: (snapshot: SessionSnapshot) => T) => T
|
|
1387
|
+
/** Injected: the ui-tweaks settings store (reads `gitBarEnabled`). */
|
|
1388
|
+
controller: SettingsClient
|
|
1389
|
+
/** Locale-bound translator for the GitBar labels. */
|
|
1390
|
+
t: Translate
|
|
1391
|
+
/** Parent closes the panel (icon toggles; outside clicks route here too). */
|
|
1392
|
+
onClose: () => void
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Shared side-panel layout: anchor the panel below the session header, and
|
|
1397
|
+
* while it is open push ONLY the message area (the conversation scrollport)
|
|
1398
|
+
* left by `width`. Never touch `#root`: in the DSH 0.1.1 column grid `#root`
|
|
1399
|
+
* wraps the whole app (left sidebar + center + details), so a `#root` margin
|
|
1400
|
+
* collapses the left sidebar and shoves the top header — the "showing the
|
|
1401
|
+
* panel moves the top" bug. The scrollport is the center column's message
|
|
1402
|
+
* region; pushing only it leaves the sidebar and header untouched, and fixed
|
|
1403
|
+
* overlays docked inside the scrollport stay visible.
|
|
1404
|
+
*
|
|
1405
|
+
* @param width - panel width; the scrollport gets this much right margin.
|
|
1406
|
+
* @param active - whether the panel is actually showing; when false (e.g. a
|
|
1407
|
+
* non-repo session opened the diff icon) no layout is pushed.
|
|
1408
|
+
* @returns the panel top (scrollport top) for `style.top`.
|
|
1409
|
+
*/
|
|
1410
|
+
function useSidePanelLayout(width: number, active = true): number {
|
|
1411
|
+
const [panelTop, setPanelTop] = useState(0)
|
|
1412
|
+
|
|
1413
|
+
useEffect(() => {
|
|
1414
|
+
const measure = (): void => {
|
|
1415
|
+
const sp = document.querySelector('[data-conversation-scroll]')
|
|
1416
|
+
const top = sp === null ? 0 : Math.round(sp.getBoundingClientRect().top)
|
|
1417
|
+
setPanelTop(prev => Math.abs(prev - top) < 2 ? prev : top)
|
|
1418
|
+
}
|
|
1419
|
+
measure()
|
|
1420
|
+
window.addEventListener('resize', measure)
|
|
1421
|
+
const observer = new ResizeObserver(measure)
|
|
1422
|
+
const sp = document.querySelector('[data-conversation-scroll]')
|
|
1423
|
+
if (sp !== null) observer.observe(sp)
|
|
1424
|
+
return () => {
|
|
1425
|
+
window.removeEventListener('resize', measure)
|
|
1426
|
+
observer.disconnect()
|
|
1427
|
+
}
|
|
1428
|
+
}, [])
|
|
1429
|
+
|
|
1430
|
+
useEffect(() => {
|
|
1431
|
+
if (!active) return
|
|
1432
|
+
const scrollport = document.querySelector('[data-conversation-scroll]') as HTMLElement | null
|
|
1433
|
+
if (scrollport !== null) scrollport.style.marginRight = `${width}px`
|
|
1434
|
+
return () => { if (scrollport !== null) scrollport.style.marginRight = '' }
|
|
1435
|
+
}, [width, active])
|
|
1436
|
+
|
|
1437
|
+
return panelTop
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
export function DiffPanel({ sessionId, useSession, controller, t, onClose }: DiffPanelProps) {
|
|
1441
|
+
const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
|
|
1442
|
+
const enabled = settingsState.value?.gitBarEnabled ?? true
|
|
1443
|
+
const sessionStr = String(sessionId)
|
|
1444
|
+
const agentRunning = useSession(snapshot => snapshot.running) ?? false
|
|
1445
|
+
|
|
1446
|
+
const [snapshot, refresh] = useGitStatus(enabled, { session: sessionStr })
|
|
1447
|
+
const [diff, setDiff] = useState<GitDiffResult | null>(null)
|
|
1448
|
+
const [diffMode, setDiffMode] = useState<'hunk' | 'full'>('hunk')
|
|
1449
|
+
const [diffPath, setDiffPath] = useState<string | null>(null)
|
|
1450
|
+
const [message, setMessage] = useState('')
|
|
1451
|
+
const [busy, setBusy] = useState<string | null>(null)
|
|
1452
|
+
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
|
1453
|
+
const [excluded, setExcluded] = useState<ReadonlySet<string>>(new Set())
|
|
1454
|
+
const [diffWidth, setDiffWidth] = useState(440)
|
|
1455
|
+
const panelTop = useSidePanelLayout(diffWidth, enabled && sessionStr !== undefined && snapshot !== null && snapshot.isRepo)
|
|
1456
|
+
const [filesHeight, setFilesHeight] = useState<number | null>(null)
|
|
1457
|
+
const [commitHeight, setCommitHeight] = useState<number | null>(null)
|
|
1458
|
+
const [dragging, setDragging] = useState<'files' | 'commit' | null>(null)
|
|
1459
|
+
|
|
1460
|
+
const showNotice = (kind: 'ok' | 'err', text: string): void => {
|
|
1461
|
+
setNotice({ kind, text })
|
|
1462
|
+
window.setTimeout(() => setNotice(null), 4000)
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
const run = async (label: string, fn: () => Promise<void>): Promise<boolean> => {
|
|
1466
|
+
if (busy !== null) return false
|
|
1467
|
+
setBusy(label)
|
|
1468
|
+
try {
|
|
1469
|
+
await fn()
|
|
1470
|
+
return true
|
|
1471
|
+
} catch (cause) {
|
|
1472
|
+
showNotice('err', cause instanceof Error ? cause.message : String(cause))
|
|
1473
|
+
return false
|
|
1474
|
+
} finally {
|
|
1475
|
+
setBusy(null)
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const toggleExclude = (path: string): void => {
|
|
1480
|
+
setExcluded(current => {
|
|
1481
|
+
const next = new Set(current)
|
|
1482
|
+
if (next.has(path)) next.delete(path)
|
|
1483
|
+
else next.add(path)
|
|
1484
|
+
return next
|
|
1485
|
+
})
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Auto-select the first changed file so the panel opens with a diff. Runs
|
|
1489
|
+
// when the snapshot first arrives (it is null on mount), and again only if
|
|
1490
|
+
// the user has not picked a file yet.
|
|
1491
|
+
useEffect(() => {
|
|
1492
|
+
void refresh()
|
|
1493
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1494
|
+
}, [])
|
|
1495
|
+
|
|
1496
|
+
useEffect(() => {
|
|
1497
|
+
setDiffPath(current => current ?? snapshot?.files[0]?.path ?? null)
|
|
1498
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1499
|
+
}, [snapshot])
|
|
1500
|
+
|
|
1501
|
+
const selectDiffFile = (path: string): void => {
|
|
1502
|
+
setDiffPath(path)
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const switchDiffMode = (mode: 'hunk' | 'full'): void => {
|
|
1506
|
+
setDiffMode(mode)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// Load the selected file's diff when the selection/mode changes.
|
|
1510
|
+
useEffect(() => {
|
|
1511
|
+
if (sessionStr === undefined || diffPath === null) return
|
|
1512
|
+
let cancelled = false
|
|
1513
|
+
setDiff(null)
|
|
1514
|
+
void apiGet<GitDiffResult>(
|
|
1515
|
+
`${GIT_ROUTE}/diff?${targetQuery({ session: sessionStr })}&file=${encodeURIComponent(diffPath)}&mode=${diffMode}`,
|
|
1516
|
+
).then(result => {
|
|
1517
|
+
if (!cancelled) setDiff(result)
|
|
1518
|
+
}).catch(cause => {
|
|
1519
|
+
if (!cancelled) showNotice('err', cause instanceof Error ? cause.message : String(cause))
|
|
1520
|
+
})
|
|
1521
|
+
return () => { cancelled = true }
|
|
1522
|
+
}, [sessionStr, diffPath, diffMode])
|
|
1523
|
+
|
|
1524
|
+
const doCommit = (push: boolean): void => {
|
|
1525
|
+
if (agentRunning) return
|
|
1526
|
+
void run(push ? 'commit-push' : 'commit', async () => {
|
|
1527
|
+
const msg = message.trim()
|
|
1528
|
+
if (msg === '') {
|
|
1529
|
+
showNotice('err', t('commitEmpty'))
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
const result = await apiPost<{ hash?: string; pushed: boolean }>(`${GIT_ROUTE}/commit`, {
|
|
1533
|
+
session: sessionStr,
|
|
1534
|
+
message: msg,
|
|
1535
|
+
push,
|
|
1536
|
+
exclude: [...excluded],
|
|
1537
|
+
})
|
|
1538
|
+
onClose()
|
|
1539
|
+
setMessage('')
|
|
1540
|
+
setExcluded(new Set())
|
|
1541
|
+
await refresh()
|
|
1542
|
+
showNotice('ok', push ? `✓ ${result.hash ?? 'committed'} · pushed` : `✓ ${result.hash ?? 'committed'}`)
|
|
1543
|
+
})
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Close the diff panel when clicking outside it (the 差异 icon toggles).
|
|
1547
|
+
// The icon lives in `.gbar-hicons`, which is NOT a descendant of `.gbar`,
|
|
1548
|
+
// so that container must be excluded too — otherwise mousedown on the icon
|
|
1549
|
+
// fires onClose() and the following click re-opens the panel ("sometimes
|
|
1550
|
+
// it opens by itself").
|
|
1551
|
+
useEffect(() => {
|
|
1552
|
+
const onDown = (event: MouseEvent): void => {
|
|
1553
|
+
const target = event.target as Node | null
|
|
1554
|
+
if (target === null) return
|
|
1555
|
+
if (document.querySelector('.gbar-side')?.contains(target)) return
|
|
1556
|
+
if (document.querySelector('.gbar-hicons')?.contains(target)) return
|
|
1557
|
+
if (document.querySelector('.gbar')?.contains(target)) return
|
|
1558
|
+
onClose()
|
|
1559
|
+
}
|
|
1560
|
+
document.addEventListener('mousedown', onDown)
|
|
1561
|
+
return () => { document.removeEventListener('mousedown', onDown) }
|
|
1562
|
+
}, [onClose])
|
|
1563
|
+
|
|
1564
|
+
// Drag the panel's left edge to resize it. A press WITHOUT any drag counts
|
|
1565
|
+
// as a click: it snaps the panel to half the screen (or back to the width
|
|
1566
|
+
// it had before expanding), so the edge doubles as a one-button maximize.
|
|
1567
|
+
const prevWidthRef = useRef<number | null>(null)
|
|
1568
|
+
const toggleHalfWidth = (): void => {
|
|
1569
|
+
const half = Math.round(window.innerWidth / 2)
|
|
1570
|
+
if (diffWidth >= half - 4) {
|
|
1571
|
+
const restore = prevWidthRef.current ?? 440
|
|
1572
|
+
prevWidthRef.current = null
|
|
1573
|
+
setDiffWidth(restore)
|
|
1574
|
+
} else {
|
|
1575
|
+
prevWidthRef.current = diffWidth
|
|
1576
|
+
setDiffWidth(half)
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const startResize = (event: { clientX: number; preventDefault: () => void }): void => {
|
|
1581
|
+
event.preventDefault()
|
|
1582
|
+
const startX = event.clientX
|
|
1583
|
+
const startWidth = diffWidth
|
|
1584
|
+
// Manual drags may reach half the screen too — otherwise an expanded
|
|
1585
|
+
// half-screen panel would instantly clamp back to the old 900px ceiling.
|
|
1586
|
+
const maxWidth = Math.max(900, Math.round(window.innerWidth / 2))
|
|
1587
|
+
let moved = false
|
|
1588
|
+
const onMove = (move: PointerEvent): void => {
|
|
1589
|
+
if (!moved && Math.abs(move.clientX - startX) > 3) moved = true
|
|
1590
|
+
setDiffWidth(Math.min(maxWidth, Math.max(320, startWidth - (move.clientX - startX))))
|
|
1591
|
+
}
|
|
1592
|
+
const onUp = (): void => {
|
|
1593
|
+
window.removeEventListener('pointermove', onMove)
|
|
1594
|
+
window.removeEventListener('pointerup', onUp)
|
|
1595
|
+
if (!moved) toggleHalfWidth()
|
|
1596
|
+
}
|
|
1597
|
+
window.addEventListener('pointermove', onMove)
|
|
1598
|
+
window.addEventListener('pointerup', onUp)
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Drag a horizontal splitter to redistribute height between the panel's
|
|
1602
|
+
// sections. Only the dragged section gets an explicit height; the diff pane is
|
|
1603
|
+
// the flexible one, so every pixel a neighbour gains comes out of the diff and
|
|
1604
|
+
// the total never exceeds the panel. Double-click restores automatic sizing.
|
|
1605
|
+
const startVResize = (
|
|
1606
|
+
target: 'files' | 'commit',
|
|
1607
|
+
event: { clientY: number; preventDefault: () => void },
|
|
1608
|
+
): void => {
|
|
1609
|
+
event.preventDefault()
|
|
1610
|
+
const panel = document.querySelector('.gbar-side')
|
|
1611
|
+
const measure = (selector: string): number => {
|
|
1612
|
+
const el = panel?.querySelector(selector) ?? null
|
|
1613
|
+
return el === null ? 0 : el.getBoundingClientRect().height
|
|
1614
|
+
}
|
|
1615
|
+
const startY = event.clientY
|
|
1616
|
+
const startFiles = measure('.gbar-files')
|
|
1617
|
+
const startCommit = measure('.gbar-side-commit')
|
|
1618
|
+
const startDiff = measure('.gbar-diff')
|
|
1619
|
+
setDragging(target)
|
|
1620
|
+
const onMove = (move: PointerEvent): void => {
|
|
1621
|
+
const delta = move.clientY - startY
|
|
1622
|
+
if (target === 'files') {
|
|
1623
|
+
// Dragging down grows the file list and shrinks the diff.
|
|
1624
|
+
const ceiling = Math.max(MIN_FILES_H, startFiles + startDiff - MIN_DIFF_H)
|
|
1625
|
+
setFilesHeight(Math.round(Math.min(ceiling, Math.max(MIN_FILES_H, startFiles + delta))))
|
|
1626
|
+
} else {
|
|
1627
|
+
// Dragging up grows the commit band and shrinks the diff.
|
|
1628
|
+
const ceiling = Math.max(MIN_COMMIT_H, startCommit + startDiff - MIN_DIFF_H)
|
|
1629
|
+
setCommitHeight(Math.round(Math.min(ceiling, Math.max(MIN_COMMIT_H, startCommit - delta))))
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
const onUp = (): void => {
|
|
1633
|
+
setDragging(null)
|
|
1634
|
+
window.removeEventListener('pointermove', onMove)
|
|
1635
|
+
window.removeEventListener('pointerup', onUp)
|
|
1636
|
+
}
|
|
1637
|
+
window.addEventListener('pointermove', onMove)
|
|
1638
|
+
window.addEventListener('pointerup', onUp)
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (!enabled || sessionStr === undefined) return null
|
|
1642
|
+
// Not a repo: no panel at all (the pills hide for non-repos too).
|
|
1643
|
+
if (snapshot !== null && !snapshot.isRepo) return null
|
|
1644
|
+
|
|
1645
|
+
// Cold start (no cached snapshot yet): paint the panel SHELL at once with a
|
|
1646
|
+
// loading body instead of returning null — the click must give immediate
|
|
1647
|
+
// feedback even while the first /status round-trip is in flight. With the
|
|
1648
|
+
// shared snapshot cache this state is rare and brief.
|
|
1649
|
+
if (snapshot === null) {
|
|
1650
|
+
return createPortal(
|
|
1651
|
+
<div className="gbar-side" role="dialog" aria-label={t('diffTitle')} style={{ width: `${diffWidth}px`, top: `${panelTop}px` }}>
|
|
1652
|
+
<div className="gbar-resize" onPointerDown={startResize} title="拖动调整宽度 · 点击展开到半屏" />
|
|
1653
|
+
<div className="gbar-side-head">
|
|
1654
|
+
<span className="gbar-title">{t('diffTitle')}</span>
|
|
1655
|
+
<span className="gbar-spacer" />
|
|
1656
|
+
<button type="button" className="gbar-side-x" onClick={onClose} aria-label="✕">✕</button>
|
|
1657
|
+
</div>
|
|
1658
|
+
<div className="gbar-side-body">
|
|
1659
|
+
<div className="gbar-diff"><div className="gbar-empty"><span className="gbar-spin" /> {t('loading')}</div></div>
|
|
1660
|
+
</div>
|
|
1661
|
+
</div>,
|
|
1662
|
+
document.body,
|
|
1663
|
+
)
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const dirty = !snapshot.clean
|
|
1667
|
+
const halfActive = diffWidth >= Math.round(window.innerWidth / 2) - 4
|
|
1668
|
+
|
|
1669
|
+
return createPortal(
|
|
1670
|
+
<>
|
|
1671
|
+
<div className="gbar-side" role="dialog" aria-label={t('diffTitle')} style={{ width: `${diffWidth}px`, top: `${panelTop}px` }}>
|
|
1672
|
+
<div className="gbar-resize" onPointerDown={startResize} title="拖动调整宽度 · 点击展开到半屏" />
|
|
1673
|
+
<div className="gbar-side-head">
|
|
1674
|
+
<span className="gbar-title">{diffPath ?? t('diffTitle')}</span>
|
|
1675
|
+
<span className="gbar-sub">
|
|
1676
|
+
{snapshot.files.length} {t('diffFiles')} · <span className="gbar-a">+{snapshot.totalAdded}</span> <span className="gbar-d">−{snapshot.totalDeleted}</span>
|
|
1677
|
+
</span>
|
|
1678
|
+
<span className="gbar-spacer" />
|
|
1679
|
+
<div className="gbar-seg" role="group">
|
|
1680
|
+
<button type="button" className={diffMode === 'hunk' ? 'gbar-on' : ''} onClick={() => { switchDiffMode('hunk') }}>{t('diffOnly')}</button>
|
|
1681
|
+
<button type="button" className={diffMode === 'full' ? 'gbar-on' : ''} onClick={() => { switchDiffMode('full') }}>{t('diffFull')}</button>
|
|
1682
|
+
</div>
|
|
1683
|
+
<button
|
|
1684
|
+
type="button"
|
|
1685
|
+
className={'gbar-side-half' + (halfActive ? ' gbar-on' : '')}
|
|
1686
|
+
onClick={toggleHalfWidth}
|
|
1687
|
+
title="展开到半屏 / 恢复宽度"
|
|
1688
|
+
aria-pressed={halfActive}
|
|
1689
|
+
>
|
|
1690
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
|
1691
|
+
<path d="M6 3H3v3" /><path d="M10 13h3v-3" /><path d="M3 3l4 4" /><path d="M13 13L9 9" />
|
|
1692
|
+
</svg>
|
|
1693
|
+
</button>
|
|
1694
|
+
<button type="button" className="gbar-side-x" onClick={onClose} aria-label="✕">✕</button>
|
|
1695
|
+
</div>
|
|
1696
|
+
<div className="gbar-side-body">
|
|
1697
|
+
<div
|
|
1698
|
+
className="gbar-files"
|
|
1699
|
+
style={filesHeight === null
|
|
1700
|
+
? { maxHeight: FILES_AUTO_MAX }
|
|
1701
|
+
: { height: filesHeight, maxHeight: SECTION_MAX }}
|
|
1702
|
+
>
|
|
1703
|
+
{snapshot.files.map(file => {
|
|
1704
|
+
const isExcluded = excluded.has(file.path)
|
|
1705
|
+
// Split the path so the directory renders dim and the filename
|
|
1706
|
+
// bright — the eye scans by filename, not by folder.
|
|
1707
|
+
const slash = file.path.lastIndexOf('/')
|
|
1708
|
+
const dir = slash >= 0 ? file.path.slice(0, slash + 1) : ''
|
|
1709
|
+
const base = slash >= 0 ? file.path.slice(slash + 1) : file.path
|
|
1710
|
+
return (
|
|
1711
|
+
<div key={file.path} className={'gbar-file' + (diffPath === file.path ? ' gbar-on' : '') + (isExcluded ? ' gbar-excl' : '')}>
|
|
1712
|
+
<button
|
|
1713
|
+
type="button"
|
|
1714
|
+
className={'gbar-chk' + (isExcluded ? ' gbar-off' : '')}
|
|
1715
|
+
onClick={() => { toggleExclude(file.path) }}
|
|
1716
|
+
aria-label={isExcluded ? t('excludeFile') : t('includeFile')}
|
|
1717
|
+
>
|
|
1718
|
+
{isExcluded ? '✕' : '✓'}
|
|
1719
|
+
</button>
|
|
1720
|
+
<button
|
|
1721
|
+
type="button"
|
|
1722
|
+
className="gbar-file-main"
|
|
1723
|
+
onClick={() => { selectDiffFile(file.path) }}
|
|
1724
|
+
title={file.path}
|
|
1725
|
+
>
|
|
1726
|
+
<span className={'gbar-st ' + statusClass(file.status)}>{statusLetter(file.status)}</span>
|
|
1727
|
+
<span className="gbar-path">{dir !== '' ? <span className="gbar-dir">{dir}</span> : null}<span>{base}</span></span>
|
|
1728
|
+
<span className="gbar-nums">
|
|
1729
|
+
<span className="gbar-a">+{file.added}</span> <span className="gbar-d">−{file.deleted}</span>
|
|
1730
|
+
</span>
|
|
1731
|
+
</button>
|
|
1732
|
+
</div>
|
|
1733
|
+
)
|
|
1734
|
+
})}
|
|
1735
|
+
</div>
|
|
1736
|
+
<div
|
|
1737
|
+
className={'gbar-vsplit' + (dragging === 'files' ? ' gbar-dragging' : '')}
|
|
1738
|
+
role="separator"
|
|
1739
|
+
aria-orientation="horizontal"
|
|
1740
|
+
title="拖动调整文件列表高度(双击复位)"
|
|
1741
|
+
onPointerDown={event => { startVResize('files', event) }}
|
|
1742
|
+
onDoubleClick={() => { setFilesHeight(null) }}
|
|
1743
|
+
/>
|
|
1744
|
+
{diff === null ? (
|
|
1745
|
+
<div className="gbar-diff"><div className="gbar-empty">{snapshot.clean || snapshot.files.length === 0 ? t('clean') : t('loading')}</div></div>
|
|
1746
|
+
) : diff.lines.length === 0 ? (
|
|
1747
|
+
<div className="gbar-diff"><div className="gbar-empty">{t('noChanges')}</div></div>
|
|
1748
|
+
) : (
|
|
1749
|
+
<div className="gbar-diff">
|
|
1750
|
+
{diff.lines.map((line, i) => (
|
|
1751
|
+
<div key={i} className={'gbar-line gbar-' + line.type}>
|
|
1752
|
+
<span className="gbar-ln">{line.old ?? ''}</span>
|
|
1753
|
+
<span className="gbar-ln">{line.new ?? ''}</span>
|
|
1754
|
+
<span className="gbar-code">{line.text}</span>
|
|
1755
|
+
</div>
|
|
1756
|
+
))}
|
|
1757
|
+
{diff.truncated ? <div className="gbar-empty">…</div> : null}
|
|
1758
|
+
</div>
|
|
1759
|
+
)}
|
|
1760
|
+
{dirty ? (
|
|
1761
|
+
<div
|
|
1762
|
+
className={'gbar-vsplit' + (dragging === 'commit' ? ' gbar-dragging' : '')}
|
|
1763
|
+
role="separator"
|
|
1764
|
+
aria-orientation="horizontal"
|
|
1765
|
+
title="拖动调整提交区高度(双击复位)"
|
|
1766
|
+
onPointerDown={event => { startVResize('commit', event) }}
|
|
1767
|
+
onDoubleClick={() => { setCommitHeight(null) }}
|
|
1768
|
+
/>
|
|
1769
|
+
) : null}
|
|
1770
|
+
</div>
|
|
1771
|
+
<div className="gbar-side-foot">
|
|
1772
|
+
<span className="gbar-dot" />
|
|
1773
|
+
{snapshot.branch ?? '—'} · {dirty ? t('dirty') : t('clean')}
|
|
1774
|
+
</div>
|
|
1775
|
+
{dirty ? (
|
|
1776
|
+
<div
|
|
1777
|
+
className="gbar-side-commit"
|
|
1778
|
+
style={commitHeight === null ? undefined : { height: commitHeight, maxHeight: SECTION_MAX }}
|
|
1779
|
+
>
|
|
1780
|
+
<div className="gbar-crow">
|
|
1781
|
+
<textarea
|
|
1782
|
+
value={message}
|
|
1783
|
+
onChange={event => { setMessage(event.target.value) }}
|
|
1784
|
+
onKeyDown={event => {
|
|
1785
|
+
// Enter commits; Shift+Enter adds a line, now that this field
|
|
1786
|
+
// can be dragged tall enough for a multi-line message.
|
|
1787
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
1788
|
+
event.preventDefault()
|
|
1789
|
+
doCommit(false)
|
|
1790
|
+
}
|
|
1791
|
+
}}
|
|
1792
|
+
placeholder={t('commitPlaceholder')}
|
|
1793
|
+
aria-label={t('commitMessage')}
|
|
1794
|
+
rows={1}
|
|
1795
|
+
/>
|
|
1796
|
+
</div>
|
|
1797
|
+
<div className="gbar-actions">
|
|
1798
|
+
<button
|
|
1799
|
+
type="button"
|
|
1800
|
+
className="gbar-btn gbar-soft"
|
|
1801
|
+
onClick={() => { doCommit(false) }}
|
|
1802
|
+
disabled={busy !== null || agentRunning}
|
|
1803
|
+
title={agentRunning ? t('commitBusy') : undefined}
|
|
1804
|
+
>
|
|
1805
|
+
{busy === 'commit' ? <span className="gbar-spin" /> : null} {t('commitSubmit')}
|
|
1806
|
+
</button>
|
|
1807
|
+
<button
|
|
1808
|
+
type="button"
|
|
1809
|
+
className="gbar-btn gbar-primary"
|
|
1810
|
+
onClick={() => { doCommit(true) }}
|
|
1811
|
+
disabled={busy !== null || agentRunning}
|
|
1812
|
+
title={agentRunning ? t('commitBusy') : undefined}
|
|
1813
|
+
>
|
|
1814
|
+
{busy === 'commit-push' ? <span className="gbar-spin" /> : null} {t('commitSubmitPush')}
|
|
1815
|
+
</button>
|
|
1816
|
+
</div>
|
|
1817
|
+
</div>
|
|
1818
|
+
) : null}
|
|
1819
|
+
</div>
|
|
1820
|
+
{/* Transient notice */}
|
|
1821
|
+
{notice !== null ? (
|
|
1822
|
+
<div className={'gbar-notice gbar-' + notice.kind} role="status">{notice.text}</div>
|
|
1823
|
+
) : null}
|
|
1824
|
+
</>,
|
|
1825
|
+
document.body,
|
|
1826
|
+
)
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// ---------------------------------------------------------------------------
|
|
1830
|
+
// Terminal panel — a REAL terminal: xterm.js in the browser over a WebSocket
|
|
1831
|
+
// to the host's persistent node-pty shell (the dsh-better-sidebar design,
|
|
1832
|
+
// its src/pty-manager.ts + TerminalView.tsx). Full emulation: colors, cursor
|
|
1833
|
+
// control, Ctrl+C, command history, interactive apps. The xterm UMD builds
|
|
1834
|
+
// and stylesheet are vendored by the plugin and lazy-loaded on first panel
|
|
1835
|
+
// open, so the always-resident GitBar bundle stays lean.
|
|
1836
|
+
//
|
|
1837
|
+
// Session lifetime mirrors better-sidebar tabs: closing the panel only drops
|
|
1838
|
+
// the socket — the host keeps the shell alive behind a reconnect grace, so
|
|
1839
|
+
// re-opening the panel reattaches to the SAME session with its transcript
|
|
1840
|
+
// replayed. Page refreshes recover the same way.
|
|
1841
|
+
// ---------------------------------------------------------------------------
|
|
1842
|
+
|
|
1843
|
+
/** Vendored xterm assets served by the host half (src/git-web.ts). */
|
|
1844
|
+
const VENDOR_BASE = `${GIT_ROUTE}/vendor`
|
|
1845
|
+
|
|
1846
|
+
/** Duck-typed face of the xterm Terminal instance this panel touches. */
|
|
1847
|
+
interface XTermLike {
|
|
1848
|
+
cols: number
|
|
1849
|
+
rows: number
|
|
1850
|
+
/** Live option updates (`theme` re-render is supported by xterm 5). */
|
|
1851
|
+
options: { theme?: Record<string, string> | undefined }
|
|
1852
|
+
open(host: HTMLElement): void
|
|
1853
|
+
write(data: string): void
|
|
1854
|
+
focus(): void
|
|
1855
|
+
resize(cols: number, rows: number): void
|
|
1856
|
+
dispose(): void
|
|
1857
|
+
loadAddon(addon: unknown): void
|
|
1858
|
+
onData(listener: (data: string) => void): { dispose(): void }
|
|
1859
|
+
onResize(listener: (dims: { cols: number; rows: number }) => void): { dispose(): void }
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** Duck-typed face of @xterm/addon-fit. */
|
|
1863
|
+
interface FitAddonLike {
|
|
1864
|
+
fit(): void
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
type XTermCtor = new (options: Record<string, unknown>) => XTermLike
|
|
1868
|
+
type FitAddonCtor = new () => FitAddonLike
|
|
1869
|
+
|
|
1870
|
+
interface XTermGlobals { Terminal: XTermCtor; FitAddon: FitAddonCtor }
|
|
1871
|
+
|
|
1872
|
+
let xtermGlobalsPromise: Promise<XTermGlobals> | null = null
|
|
1873
|
+
|
|
1874
|
+
/** Inject one vendored script exactly once; resolves on load, rejects on error. */
|
|
1875
|
+
function loadVendorScript(src: string): Promise<void> {
|
|
1876
|
+
return new Promise((resolve, reject) => {
|
|
1877
|
+
const existing = document.querySelector(`script[data-gbar-vendor="${CSS.escape(src)}"]`)
|
|
1878
|
+
if (existing !== null) {
|
|
1879
|
+
// An already-complete script never fires `load` again.
|
|
1880
|
+
if (existing.getAttribute('data-gbar-loaded') === '1') {
|
|
1881
|
+
resolve()
|
|
1882
|
+
return
|
|
1883
|
+
}
|
|
1884
|
+
existing.addEventListener('load', () => resolve())
|
|
1885
|
+
existing.addEventListener('error', () => reject(new Error(`failed to load ${src}`)))
|
|
1886
|
+
return
|
|
1887
|
+
}
|
|
1888
|
+
const script = document.createElement('script')
|
|
1889
|
+
script.src = src
|
|
1890
|
+
script.async = true
|
|
1891
|
+
script.dataset.gbarVendor = src
|
|
1892
|
+
script.addEventListener('load', () => {
|
|
1893
|
+
script.setAttribute('data-gbar-loaded', '1')
|
|
1894
|
+
resolve()
|
|
1895
|
+
})
|
|
1896
|
+
script.addEventListener('error', () => reject(new Error(`failed to load ${src}`)))
|
|
1897
|
+
document.head.appendChild(script)
|
|
1898
|
+
})
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/** Load the vendored xterm UMD builds once per page; caches the globals. */
|
|
1902
|
+
function loadXterm(): Promise<XTermGlobals> {
|
|
1903
|
+
xtermGlobalsPromise ??= (async () => {
|
|
1904
|
+
try {
|
|
1905
|
+
if (document.querySelector('link[data-gbar-xterm-css]') === null) {
|
|
1906
|
+
const link = document.createElement('link')
|
|
1907
|
+
link.rel = 'stylesheet'
|
|
1908
|
+
link.href = `${VENDOR_BASE}/xterm.css`
|
|
1909
|
+
link.dataset.gbarXtermCss = '1'
|
|
1910
|
+
document.head.appendChild(link)
|
|
1911
|
+
}
|
|
1912
|
+
await loadVendorScript(`${VENDOR_BASE}/xterm.js`)
|
|
1913
|
+
await loadVendorScript(`${VENDOR_BASE}/addon-fit.js`)
|
|
1914
|
+
const win = window as unknown as Record<string, unknown>
|
|
1915
|
+
const TerminalCtor = win.Terminal as XTermCtor | undefined
|
|
1916
|
+
const fitNamespace = win.FitAddon as { FitAddon?: FitAddonCtor } | undefined
|
|
1917
|
+
if (typeof TerminalCtor !== 'function' || typeof fitNamespace?.FitAddon !== 'function') {
|
|
1918
|
+
throw new Error('xterm assets loaded but globals are missing')
|
|
1919
|
+
}
|
|
1920
|
+
return { Terminal: TerminalCtor, FitAddon: fitNamespace.FitAddon }
|
|
1921
|
+
} catch (cause) {
|
|
1922
|
+
// Allow a retry on the next panel open instead of caching the failure.
|
|
1923
|
+
xtermGlobalsPromise = null
|
|
1924
|
+
throw cause
|
|
1925
|
+
}
|
|
1926
|
+
})()
|
|
1927
|
+
return xtermGlobalsPromise
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// Curated ANSI palettes (one-dark / one-light families), matching the
|
|
1931
|
+
// dsh-better-sidebar terminal so both plugins render shells identically.
|
|
1932
|
+
const ANSI_DARK: Record<string, string> = {
|
|
1933
|
+
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
|
|
1934
|
+
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
|
|
1935
|
+
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
|
|
1936
|
+
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
|
|
1937
|
+
brightCyan: '#56b6c2', brightWhite: '#ffffff',
|
|
1938
|
+
}
|
|
1939
|
+
const ANSI_LIGHT: Record<string, string> = {
|
|
1940
|
+
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
|
|
1941
|
+
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
|
|
1942
|
+
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
|
|
1943
|
+
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
|
|
1944
|
+
brightCyan: '#0997b3', brightWhite: '#fafafa',
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* Whether the DSH app is currently in its dark theme. The real switch is the
|
|
1949
|
+
* `data-ds-dark-theme` attribute the theme plugin puts on <body> — the same
|
|
1950
|
+
* signal the DSW token stylesheet keys on (`body[data-ds-dark-theme]{…}`), so
|
|
1951
|
+
* it is correct no matter how the theme was chosen (app setting or system
|
|
1952
|
+
* follow). Tokens being readable without the attribute means the light block
|
|
1953
|
+
* is active; only a composition with neither falls back to the system media
|
|
1954
|
+
* query.
|
|
1955
|
+
*/
|
|
1956
|
+
function isDarkScheme(): boolean {
|
|
1957
|
+
if (document.body.hasAttribute('data-ds-dark-theme')) return true
|
|
1958
|
+
const style = getComputedStyle(document.body)
|
|
1959
|
+
if (style.getPropertyValue('--dsw-alias-bg-base').trim() !== '') return false
|
|
1960
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* The xterm theme for the current scheme (surface from tokens, curated ANSI).
|
|
1965
|
+
* The DSW alias tokens are defined on <body> (not :root), and custom
|
|
1966
|
+
* properties inherit downward only — reading them off documentElement always
|
|
1967
|
+
* yields '', so read the body's computed values.
|
|
1968
|
+
*/
|
|
1969
|
+
function xtermTheme(): Record<string, string> & { background: string; foreground: string } {
|
|
1970
|
+
const dark = isDarkScheme()
|
|
1971
|
+
const style = getComputedStyle(document.body)
|
|
1972
|
+
const background = style.getPropertyValue('--dsw-alias-bg-base').trim() || (dark ? '#151517' : '#ffffff')
|
|
1973
|
+
const foreground = style.getPropertyValue('--dsw-alias-label-primary').trim() || (dark ? '#e6e6e6' : '#1a1a1a')
|
|
1974
|
+
return {
|
|
1975
|
+
background,
|
|
1976
|
+
foreground,
|
|
1977
|
+
cursor: foreground,
|
|
1978
|
+
cursorAccent: background,
|
|
1979
|
+
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
|
1980
|
+
...(dark ? ANSI_DARK : ANSI_LIGHT),
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
export function TerminalPanel({ sessionId, controller, t, onClose }: {
|
|
1985
|
+
sessionId: SessionId
|
|
1986
|
+
controller: SettingsClient
|
|
1987
|
+
t: Translate
|
|
1988
|
+
onClose: () => void
|
|
1989
|
+
}) {
|
|
1990
|
+
const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
|
|
1991
|
+
const enabled = settingsState.value?.gitBarEnabled ?? true
|
|
1992
|
+
const target: GitTarget = { session: String(sessionId) }
|
|
1993
|
+
// Warm from the shared snapshot cache, so the cwd label paints instantly.
|
|
1994
|
+
const [snapshot] = useGitStatus(enabled, target)
|
|
1995
|
+
const [width, setWidth] = useState(520)
|
|
1996
|
+
const [status, setStatus] = useState<'boot' | 'connecting' | 'live' | 'exited' | 'error'>('boot')
|
|
1997
|
+
const [errorText, setErrorText] = useState('')
|
|
1998
|
+
const [retryNonce, setRetryNonce] = useState(0)
|
|
1999
|
+
const panelTop = useSidePanelLayout(width, enabled)
|
|
2000
|
+
const prevWidthRef = useRef<number | null>(null)
|
|
2001
|
+
const hostRef = useRef<HTMLDivElement | null>(null)
|
|
2002
|
+
|
|
2003
|
+
const toggleHalfWidth = (): void => {
|
|
2004
|
+
const half = Math.round(window.innerWidth / 2)
|
|
2005
|
+
if (width >= half - 4) {
|
|
2006
|
+
const restore = prevWidthRef.current ?? 520
|
|
2007
|
+
prevWidthRef.current = null
|
|
2008
|
+
setWidth(restore)
|
|
2009
|
+
} else {
|
|
2010
|
+
prevWidthRef.current = width
|
|
2011
|
+
setWidth(half)
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// Attach xterm + the WebSocket bridge. Re-runs on retryNonce (manual retry
|
|
2016
|
+
// after a fatal error). Teardown closes the socket WITHOUT a close frame,
|
|
2017
|
+
// so the host's reconnect grace keeps the shell alive for the next panel
|
|
2018
|
+
// open — exactly like switching tabs in dsh-better-sidebar.
|
|
2019
|
+
useEffect(() => {
|
|
2020
|
+
const host = hostRef.current
|
|
2021
|
+
if (!enabled || host === null) return
|
|
2022
|
+
let disposed = false
|
|
2023
|
+
let socket: WebSocket | null = null
|
|
2024
|
+
let retryTimer: number | undefined
|
|
2025
|
+
let failures = 0
|
|
2026
|
+
let term: XTermLike | null = null
|
|
2027
|
+
const cleanups: Array<() => void> = []
|
|
2028
|
+
|
|
2029
|
+
const sendFrame = (frame: Record<string, unknown>): void => {
|
|
2030
|
+
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame))
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
const connect = (): void => {
|
|
2034
|
+
if (disposed || term === null) return
|
|
2035
|
+
setStatus('connecting')
|
|
2036
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
2037
|
+
socket = new WebSocket(`${proto}//${location.host}${GIT_ROUTE}/terminal-ws?${targetQuery(target)}&cols=${term.cols}&rows=${term.rows}`)
|
|
2038
|
+
socket.onopen = () => {
|
|
2039
|
+
failures = 0
|
|
2040
|
+
setStatus('live')
|
|
2041
|
+
setErrorText('')
|
|
2042
|
+
if (term !== null) sendFrame({ type: 'resize', cols: term.cols, rows: term.rows })
|
|
2043
|
+
}
|
|
2044
|
+
socket.onmessage = (event) => {
|
|
2045
|
+
if (typeof event.data !== 'string' || term === null) return
|
|
2046
|
+
if (event.data.startsWith('{')) {
|
|
2047
|
+
try {
|
|
2048
|
+
const frame = JSON.parse(event.data) as { type?: unknown }
|
|
2049
|
+
if (frame.type === 'exit') {
|
|
2050
|
+
setStatus('exited')
|
|
2051
|
+
return
|
|
2052
|
+
}
|
|
2053
|
+
} catch {
|
|
2054
|
+
// Literal braces typed into the shell — render them.
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
term.write(event.data)
|
|
2058
|
+
}
|
|
2059
|
+
socket.onclose = (event) => {
|
|
2060
|
+
socket = null
|
|
2061
|
+
if (disposed) return
|
|
2062
|
+
// A reasoned 1011 refusal is fatal (spawn failure / pty missing);
|
|
2063
|
+
// every other drop recovers with backoff — the host replays the
|
|
2064
|
+
// transcript on reconnect, so the retry is seamless.
|
|
2065
|
+
if (event.code === 1011 && event.reason !== '') {
|
|
2066
|
+
setStatus('error')
|
|
2067
|
+
setErrorText(event.reason === 'pty-unavailable' ? t('termUnavailable') : event.reason)
|
|
2068
|
+
return
|
|
2069
|
+
}
|
|
2070
|
+
failures += 1
|
|
2071
|
+
if (failures > 5) {
|
|
2072
|
+
setStatus('error')
|
|
2073
|
+
setErrorText(`${t('termLost')} (${event.code})`)
|
|
2074
|
+
return
|
|
2075
|
+
}
|
|
2076
|
+
retryTimer = window.setTimeout(connect, Math.min(8000, 400 * failures))
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
void (async () => {
|
|
2081
|
+
try {
|
|
2082
|
+
const globals = await loadXterm()
|
|
2083
|
+
if (disposed) return
|
|
2084
|
+
// Surface colors track the app theme: the host backdrop matches the
|
|
2085
|
+
// theme background exactly (the xterm viewport is transparent), and a
|
|
2086
|
+
// theme flip while the panel is open re-applies live.
|
|
2087
|
+
const applyTheme = (): void => {
|
|
2088
|
+
const theme = xtermTheme()
|
|
2089
|
+
host.style.backgroundColor = theme.background
|
|
2090
|
+
if (term !== null) term.options.theme = theme
|
|
2091
|
+
}
|
|
2092
|
+
term = new globals.Terminal({
|
|
2093
|
+
cursorBlink: true,
|
|
2094
|
+
fontSize: 12.5,
|
|
2095
|
+
fontFamily: '"SF Mono",ui-monospace,Consolas,"Courier New",monospace',
|
|
2096
|
+
scrollback: 4000,
|
|
2097
|
+
theme: xtermTheme(),
|
|
2098
|
+
})
|
|
2099
|
+
applyTheme()
|
|
2100
|
+
const fit = new globals.FitAddon()
|
|
2101
|
+
term.loadAddon(fit)
|
|
2102
|
+
term.open(host)
|
|
2103
|
+
try { fit.fit() } catch { /* zero-size host before layout settles */ }
|
|
2104
|
+
term.onData(data => sendFrame({ type: 'input', data }))
|
|
2105
|
+
term.onResize(dims => sendFrame({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
2106
|
+
const observer = new ResizeObserver(() => { try { fit.fit() } catch { /* mid-layout */ } })
|
|
2107
|
+
observer.observe(host)
|
|
2108
|
+
// DSH flips themes by toggling body's data-ds-dark-theme attribute.
|
|
2109
|
+
const themeObserver = new MutationObserver(applyTheme)
|
|
2110
|
+
themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
|
2111
|
+
cleanups.push(() => observer.disconnect(), () => themeObserver.disconnect(), () => term?.dispose())
|
|
2112
|
+
connect()
|
|
2113
|
+
} catch (cause) {
|
|
2114
|
+
if (disposed) return
|
|
2115
|
+
setStatus('error')
|
|
2116
|
+
setErrorText(cause instanceof Error ? cause.message : String(cause))
|
|
2117
|
+
}
|
|
2118
|
+
})()
|
|
2119
|
+
|
|
2120
|
+
return () => {
|
|
2121
|
+
disposed = true
|
|
2122
|
+
if (retryTimer !== undefined) clearTimeout(retryTimer)
|
|
2123
|
+
socket?.close()
|
|
2124
|
+
for (const cleanup of cleanups.reverse()) cleanup()
|
|
2125
|
+
}
|
|
2126
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2127
|
+
}, [enabled, retryNonce])
|
|
2128
|
+
|
|
2129
|
+
if (!enabled) return null
|
|
2130
|
+
|
|
2131
|
+
return createPortal(
|
|
2132
|
+
<div className="gbar-side" role="dialog" aria-label={t('terminal')} style={{ width: `${width}px`, top: `${panelTop}px` }}>
|
|
2133
|
+
<div className="side-head gbar-side-head">
|
|
2134
|
+
<span className="stitle gbar-title">{t('terminal')}</span>
|
|
2135
|
+
<span className="ssub gbar-sub" style={{ fontFamily: '"SF Mono",ui-monospace,Consolas,monospace' }}>{basenameOf(snapshot?.cwd)}</span>
|
|
2136
|
+
<span className="sp gbar-spacer" />
|
|
2137
|
+
{status !== 'live' && status !== 'error' ? (
|
|
2138
|
+
<span className="ssub gbar-sub">{status === 'exited' ? t('termExited') : t('loading')}</span>
|
|
2139
|
+
) : null}
|
|
2140
|
+
<button
|
|
2141
|
+
type="button"
|
|
2142
|
+
className={'gbar-side-half' + (width >= Math.round(window.innerWidth / 2) - 4 ? ' gbar-on' : '')}
|
|
2143
|
+
onClick={toggleHalfWidth}
|
|
2144
|
+
title="展开到半屏 / 恢复宽度"
|
|
2145
|
+
>
|
|
2146
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
|
2147
|
+
<path d="M6 3H3v3" /><path d="M10 13h3v-3" /><path d="M3 3l4 4" /><path d="M13 13L9 9" />
|
|
2148
|
+
</svg>
|
|
2149
|
+
</button>
|
|
2150
|
+
<button type="button" className="gbar-side-x" onClick={onClose} aria-label="✕">✕</button>
|
|
2151
|
+
</div>
|
|
2152
|
+
<div className="gbar-term gbar-xterm" ref={hostRef} />
|
|
2153
|
+
{status === 'error' ? (
|
|
2154
|
+
<div className="gbar-term-error" role="alert">
|
|
2155
|
+
<span className="gbar-term-error-text">{errorText}</span>
|
|
2156
|
+
<button type="button" onClick={() => { setStatus('boot'); setErrorText(''); setRetryNonce(n => n + 1) }}>↻</button>
|
|
2157
|
+
</div>
|
|
2158
|
+
) : null}
|
|
2159
|
+
</div>,
|
|
2160
|
+
document.body,
|
|
2161
|
+
)
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// ---------------------------------------------------------------------------
|
|
2165
|
+
// Header utilities — `conversation.session.header.utilities` (right-aligned):
|
|
2166
|
+
// 打开项目 / 终端 / 差异. The two panels are INDEPENDENT (same position, one
|
|
2167
|
+
// at a time); each icon toggles its own.
|
|
2168
|
+
// ---------------------------------------------------------------------------
|
|
2169
|
+
|
|
2170
|
+
export interface HeaderUtilitiesProps {
|
|
2171
|
+
sessionId: SessionId
|
|
2172
|
+
useSession: <T>(selector: (snapshot: SessionSnapshot) => T) => T
|
|
2173
|
+
controller: SettingsClient
|
|
2174
|
+
t: Translate
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
export function HeaderUtilities({ sessionId, useSession, controller, t }: HeaderUtilitiesProps) {
|
|
2178
|
+
const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
|
|
2179
|
+
const enabled = settingsState.value?.gitBarEnabled ?? true
|
|
2180
|
+
const target: GitTarget = { session: String(sessionId) }
|
|
2181
|
+
const [snapshot] = useGitStatus(enabled, target)
|
|
2182
|
+
|
|
2183
|
+
const [openMenu, setOpenMenu] = useState(false)
|
|
2184
|
+
const [diffOpen, setDiffOpen] = useState(false)
|
|
2185
|
+
const [termOpen, setTermOpen] = useState(false)
|
|
2186
|
+
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
|
2187
|
+
const wrapRef = useRef<HTMLSpanElement | null>(null)
|
|
2188
|
+
const iconRef = useRef<HTMLButtonElement | null>(null)
|
|
2189
|
+
const menuRef = useRef<HTMLDivElement | null>(null)
|
|
2190
|
+
const dirty = snapshot !== null && snapshot.isRepo && !snapshot.clean
|
|
2191
|
+
|
|
2192
|
+
// Close the open-project menu on outside click / Escape.
|
|
2193
|
+
useEffect(() => {
|
|
2194
|
+
if (!openMenu) return
|
|
2195
|
+
const onDown = (event: MouseEvent): void => {
|
|
2196
|
+
const node = event.target as Node | null
|
|
2197
|
+
if (node === null) return
|
|
2198
|
+
if (wrapRef.current?.contains(node) || menuRef.current?.contains(node)) return
|
|
2199
|
+
setOpenMenu(false)
|
|
2200
|
+
}
|
|
2201
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
2202
|
+
if (event.key === 'Escape') setOpenMenu(false)
|
|
2203
|
+
}
|
|
2204
|
+
document.addEventListener('mousedown', onDown)
|
|
2205
|
+
document.addEventListener('keydown', onKey)
|
|
2206
|
+
return () => {
|
|
2207
|
+
document.removeEventListener('mousedown', onDown)
|
|
2208
|
+
document.removeEventListener('keydown', onKey)
|
|
2209
|
+
}
|
|
2210
|
+
}, [openMenu])
|
|
2211
|
+
|
|
2212
|
+
const showNotice = (kind: 'ok' | 'err', text: string): void => {
|
|
2213
|
+
setNotice({ kind, text })
|
|
2214
|
+
window.setTimeout(() => setNotice(null), 4000)
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
const runOpen = async (app: 'explorer' | 'vscode' | 'idea' | 'goland' | 'webstorm' | 'pycharm'): Promise<void> => {
|
|
2218
|
+
try {
|
|
2219
|
+
await apiPost(`${GIT_ROUTE}/open`, { ...targetFields(target), target: app })
|
|
2220
|
+
setOpenMenu(false)
|
|
2221
|
+
showNotice('ok', `✓ ${app}`)
|
|
2222
|
+
} catch (cause) {
|
|
2223
|
+
showNotice('err', cause instanceof Error ? cause.message : String(cause))
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
if (!enabled) return null
|
|
2228
|
+
|
|
2229
|
+
return (
|
|
2230
|
+
<span className="gbar-hicons" ref={wrapRef}>
|
|
2231
|
+
<button
|
|
2232
|
+
type="button"
|
|
2233
|
+
ref={iconRef}
|
|
2234
|
+
className={'gbar-hicon' + (openMenu ? ' gbar-on' : '')}
|
|
2235
|
+
onClick={() => { setOpenMenu(value => !value); setTermOpen(false); setDiffOpen(false) }}
|
|
2236
|
+
title={t('openProject')}
|
|
2237
|
+
aria-expanded={openMenu}
|
|
2238
|
+
>
|
|
2239
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
|
|
2240
|
+
<path d="M1.5 4A1.5 1.5 0 0 1 3 2.5h3l1.5 2H13A1.5 1.5 0 0 1 14.5 6v6A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4Z" />
|
|
2241
|
+
<path d="M9.5 8.25l2.2 1.35-2.2 1.35v-2.7Z" fill="currentColor" stroke="none" />
|
|
2242
|
+
</svg>
|
|
2243
|
+
</button>
|
|
2244
|
+
<button
|
|
2245
|
+
type="button"
|
|
2246
|
+
className={'gbar-hicon' + (termOpen ? ' gbar-on' : '')}
|
|
2247
|
+
onClick={() => { setTermOpen(value => !value); setDiffOpen(false); setOpenMenu(false) }}
|
|
2248
|
+
title={t('terminal')}
|
|
2249
|
+
aria-expanded={termOpen}
|
|
2250
|
+
>
|
|
2251
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
|
2252
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="2" />
|
|
2253
|
+
<path d="M4.5 6l2.5 2-2.5 2" />
|
|
2254
|
+
<path d="M8.5 10h3" />
|
|
2255
|
+
</svg>
|
|
2256
|
+
</button>
|
|
2257
|
+
<button
|
|
2258
|
+
type="button"
|
|
2259
|
+
className={'gbar-hicon' + (diffOpen ? ' gbar-on' : '')}
|
|
2260
|
+
onClick={() => { setDiffOpen(value => !value); setTermOpen(false); setOpenMenu(false) }}
|
|
2261
|
+
title={t('diffTitle')}
|
|
2262
|
+
aria-expanded={diffOpen}
|
|
2263
|
+
>
|
|
2264
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" strokeLinecap="round" strokeLinejoin="round">
|
|
2265
|
+
<path d="M9.5 1.5H4A1.5 1.5 0 0 0 2.5 3v10A1.5 1.5 0 0 0 4 14.5h8A1.5 1.5 0 0 0 13.5 13V5.5l-4-4Z" />
|
|
2266
|
+
<path d="M9.5 1.5V5.5h4" />
|
|
2267
|
+
<path d="M5.75 10h4.5M5.75 7.5h2" />
|
|
2268
|
+
</svg>
|
|
2269
|
+
{/* Uncommitted-changes dot — the same dirty signal the branch chip's
|
|
2270
|
+
tooltip carries, surfaced on the icon so the state reads at a
|
|
2271
|
+
glance without hovering or opening the diff panel. */}
|
|
2272
|
+
{dirty ? <span className="gbar-hicon-dot" aria-hidden /> : null}
|
|
2273
|
+
</button>
|
|
2274
|
+
|
|
2275
|
+
{openMenu ? (
|
|
2276
|
+
<div className="gbar-openmenu" ref={menuRef} role="menu">
|
|
2277
|
+
<button type="button" className="gbar-omrow" onClick={() => { void runOpen('explorer') }}>
|
|
2278
|
+
<span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.explorer }} />
|
|
2279
|
+
{t('openExplorer')}
|
|
2280
|
+
</button>
|
|
2281
|
+
<button type="button" className="gbar-omrow" onClick={() => { void runOpen('vscode') }}>
|
|
2282
|
+
<span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.vscode }} />
|
|
2283
|
+
{t('openVscode')}
|
|
2284
|
+
</button>
|
|
2285
|
+
<button type="button" className="gbar-omrow" onClick={() => { void runOpen('idea') }}>
|
|
2286
|
+
<span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.idea }} />
|
|
2287
|
+
{t('openIdea')}
|
|
2288
|
+
</button>
|
|
2289
|
+
<button type="button" className="gbar-omrow" onClick={() => { void runOpen('goland') }}>
|
|
2290
|
+
<span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.goland }} />
|
|
2291
|
+
{t('openGoland')}
|
|
2292
|
+
</button>
|
|
2293
|
+
<button type="button" className="gbar-omrow" onClick={() => { void runOpen('webstorm') }}>
|
|
2294
|
+
<span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.webstorm }} />
|
|
2295
|
+
{t('openWebstorm')}
|
|
2296
|
+
</button>
|
|
2297
|
+
<button type="button" className="gbar-omrow" onClick={() => { void runOpen('pycharm') }}>
|
|
2298
|
+
<span className="gbar-aicon" aria-hidden dangerouslySetInnerHTML={{ __html: APP_ICONS.pycharm }} />
|
|
2299
|
+
{t('openPycharm')}
|
|
2300
|
+
</button>
|
|
2301
|
+
</div>
|
|
2302
|
+
) : null}
|
|
2303
|
+
|
|
2304
|
+
{termOpen ? <TerminalPanel sessionId={sessionId} controller={controller} t={t} onClose={() => { setTermOpen(false) }} /> : null}
|
|
2305
|
+
{/* The diff panel opens even while the model is running (it only reads
|
|
2306
|
+
git state; committing inside stays disabled while the agent works). */}
|
|
2307
|
+
{diffOpen ? <DiffPanel sessionId={sessionId} useSession={useSession} controller={controller} t={t} onClose={() => { setDiffOpen(false) }} /> : null}
|
|
2308
|
+
|
|
2309
|
+
{notice !== null ? createPortal(
|
|
2310
|
+
<div className={'gbar-notice gbar-' + notice.kind} role="status">{notice.text}</div>,
|
|
2311
|
+
document.body,
|
|
2312
|
+
) : null}
|
|
2313
|
+
</span>
|
|
2314
|
+
)
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
// ---------------------------------------------------------------------------
|
|
2318
|
+
// Hero (new-session) branch chip — the session header does not mount on the
|
|
2319
|
+
// blank/new-session screen, so the chip floats there instead: anchored just
|
|
2320
|
+
// left of the workspace picker (`[class$="_workspace"]`, CSS-module suffix is
|
|
2321
|
+
// stable within a pinned build). Git ops resolve via the workspace title
|
|
2322
|
+
// (`ws` target); if there is no repo, nothing renders.
|
|
2323
|
+
// ---------------------------------------------------------------------------
|
|
2324
|
+
|
|
2325
|
+
const HERO_ANCHOR_SELECTOR = '[class$="_workspace"]'
|
|
2326
|
+
|
|
2327
|
+
function HeroBranchChip({ t, right, top }: {
|
|
2328
|
+
t: Translate
|
|
2329
|
+
right: number
|
|
2330
|
+
top: number
|
|
2331
|
+
}) {
|
|
2332
|
+
const wsName = useMemo(
|
|
2333
|
+
() => document.querySelector<HTMLElement>(HERO_ANCHOR_SELECTOR)?.textContent?.trim() ?? '',
|
|
2334
|
+
[],
|
|
2335
|
+
)
|
|
2336
|
+
const [snapshot, refresh] = useGitStatus(true, { ws: wsName })
|
|
2337
|
+
const [branchOpen, setBranchOpen] = useState(false)
|
|
2338
|
+
const [branches, setBranches] = useState<GitBranches | null>(null)
|
|
2339
|
+
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
|
2340
|
+
const [busy, setBusy] = useState<string | null>(null)
|
|
2341
|
+
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
|
2342
|
+
const chipRef = useRef<HTMLButtonElement | null>(null)
|
|
2343
|
+
const popRef = useRef<HTMLDivElement | null>(null)
|
|
2344
|
+
|
|
2345
|
+
const showNotice = (kind: 'ok' | 'err', text: string): void => {
|
|
2346
|
+
setNotice({ kind, text })
|
|
2347
|
+
window.setTimeout(() => setNotice(null), 4000)
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const run = async (label: string, fn: () => Promise<void>): Promise<boolean> => {
|
|
2351
|
+
if (busy !== null) return false
|
|
2352
|
+
setBusy(label)
|
|
2353
|
+
try {
|
|
2354
|
+
await fn()
|
|
2355
|
+
return true
|
|
2356
|
+
} catch (cause) {
|
|
2357
|
+
showNotice('err', cause instanceof Error ? cause.message : String(cause))
|
|
2358
|
+
return false
|
|
2359
|
+
} finally {
|
|
2360
|
+
setBusy(null)
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
const loadBranches = (): void => {
|
|
2365
|
+
void apiGet<GitBranches>(`${GIT_ROUTE}/branches?${targetQuery({ ws: wsName })}`)
|
|
2366
|
+
.then(setBranches)
|
|
2367
|
+
.catch(cause => { showNotice('err', cause instanceof Error ? cause.message : String(cause)) })
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
const pickBranch = (name: string): void => {
|
|
2371
|
+
if (name === branches?.current) return
|
|
2372
|
+
void run('checkout', async () => {
|
|
2373
|
+
await apiPost(`${GIT_ROUTE}/checkout`, { ws: wsName, branch: name })
|
|
2374
|
+
setBranchOpen(false)
|
|
2375
|
+
await refresh()
|
|
2376
|
+
loadBranches()
|
|
2377
|
+
showNotice('ok', `→ ${name}`)
|
|
2378
|
+
})
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
const deleteLocal = (name: string): void => {
|
|
2382
|
+
if (confirmDelete === name) {
|
|
2383
|
+
setConfirmDelete(null)
|
|
2384
|
+
void run('branch-delete', async () => {
|
|
2385
|
+
await apiPost(`${GIT_ROUTE}/branch-delete`, { ws: wsName, name })
|
|
2386
|
+
loadBranches()
|
|
2387
|
+
showNotice('ok', `🗑 ${name}`)
|
|
2388
|
+
})
|
|
2389
|
+
} else {
|
|
2390
|
+
setConfirmDelete(name)
|
|
2391
|
+
window.setTimeout(() => { setConfirmDelete(current => (current === name ? null : current)) }, 3000)
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
const deleteRemote = (name: string): void => {
|
|
2396
|
+
if (confirmDelete === name) {
|
|
2397
|
+
setConfirmDelete(null)
|
|
2398
|
+
void run('remote-delete', async () => {
|
|
2399
|
+
await apiPost(`${GIT_ROUTE}/remote-delete`, { ws: wsName, name })
|
|
2400
|
+
loadBranches()
|
|
2401
|
+
showNotice('ok', `🗑 ${name}`)
|
|
2402
|
+
})
|
|
2403
|
+
} else {
|
|
2404
|
+
setConfirmDelete(name)
|
|
2405
|
+
window.setTimeout(() => { setConfirmDelete(current => (current === name ? null : current)) }, 3000)
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// Close on outside click / Escape.
|
|
2410
|
+
useEffect(() => {
|
|
2411
|
+
if (!branchOpen) return
|
|
2412
|
+
const onDown = (event: MouseEvent): void => {
|
|
2413
|
+
const node = event.target as Node | null
|
|
2414
|
+
if (node === null) return
|
|
2415
|
+
if (chipRef.current?.contains(node) || popRef.current?.contains(node)) return
|
|
2416
|
+
setBranchOpen(false)
|
|
2417
|
+
}
|
|
2418
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
2419
|
+
if (event.key === 'Escape') setBranchOpen(false)
|
|
2420
|
+
}
|
|
2421
|
+
document.addEventListener('mousedown', onDown)
|
|
2422
|
+
document.addEventListener('keydown', onKey)
|
|
2423
|
+
return () => {
|
|
2424
|
+
document.removeEventListener('mousedown', onDown)
|
|
2425
|
+
document.removeEventListener('keydown', onKey)
|
|
2426
|
+
}
|
|
2427
|
+
}, [branchOpen])
|
|
2428
|
+
|
|
2429
|
+
if (snapshot === null || !snapshot.isRepo) return null
|
|
2430
|
+
|
|
2431
|
+
const dirty = !snapshot.clean
|
|
2432
|
+
|
|
2433
|
+
return (
|
|
2434
|
+
<div className="gbar-hero" style={{ top, right }}>
|
|
2435
|
+
<span className="gbar-fchip" title={snapshot.cwd}>
|
|
2436
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
2437
|
+
<path d="M1.5 4A1.5 1.5 0 0 1 3 2.5h3l1.5 2H13A1.5 1.5 0 0 1 14.5 6v6A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V4Z" />
|
|
2438
|
+
</svg>
|
|
2439
|
+
<span className="gbar-folder">{basenameOf(snapshot.cwd)}</span>
|
|
2440
|
+
</span>
|
|
2441
|
+
<span className="gbar-bwrap">
|
|
2442
|
+
<button
|
|
2443
|
+
type="button"
|
|
2444
|
+
ref={chipRef}
|
|
2445
|
+
className={'gbar-chip' + (branchOpen ? ' gbar-open' : '')}
|
|
2446
|
+
onClick={() => { setBranchOpen(value => { if (!value) loadBranches(); return !value }) }}
|
|
2447
|
+
title={dirty ? t('dirty') : t('clean')}
|
|
2448
|
+
aria-expanded={branchOpen}
|
|
2449
|
+
>
|
|
2450
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
2451
|
+
<circle cx="4.5" cy="3.5" r="1.8" />
|
|
2452
|
+
<circle cx="4.5" cy="12.5" r="1.8" />
|
|
2453
|
+
<circle cx="12" cy="5.5" r="1.8" />
|
|
2454
|
+
<path d="M4.5 5.3v5.4" />
|
|
2455
|
+
<path d="M12 7.3c0 3-3.5 4.8-7.5 3.4" />
|
|
2456
|
+
</svg>
|
|
2457
|
+
<span className="gbar-bname">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
|
|
2458
|
+
</button>
|
|
2459
|
+
{branchOpen ? (
|
|
2460
|
+
<div className="gbar-pop" ref={popRef} role="menu">
|
|
2461
|
+
<div className="gbar-pop-head">
|
|
2462
|
+
<svg style={{ width: 14, height: 14, color: 'var(--dsw-alias-state-business-primary)', flex: 'none' }} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"><circle cx="4.5" cy="3.5" r="1.8" /><circle cx="4.5" cy="12.5" r="1.8" /><circle cx="12" cy="5.5" r="1.8" /><path d="M4.5 5.3v5.4" /><path d="M12 7.3c0 3-3.5 4.8-7.5 3.4" /></svg>
|
|
2463
|
+
<span className="cname">{snapshot.branch ?? snapshot.detachedHead ?? '—'}</span>
|
|
2464
|
+
<span className="state">
|
|
2465
|
+
<span style={{ width: 6, height: 6, borderRadius: 999, background: dirty ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)' }} />
|
|
2466
|
+
{snapshot.ahead > 0 ? <span>↑{snapshot.ahead}</span> : null}
|
|
2467
|
+
{snapshot.behind > 0 ? <span>↓{snapshot.behind}</span> : null}
|
|
2468
|
+
</span>
|
|
2469
|
+
</div>
|
|
2470
|
+
<div className="gbar-pop-body">
|
|
2471
|
+
<div className="gbar-sec">{t('branchLocal')}{branches !== null ? <span className="gbar-count">{branches.local.length}</span> : null}</div>
|
|
2472
|
+
{branches === null ? (
|
|
2473
|
+
<div className="gbar-loading"><span className="gbar-spin" />{t('loading')}</div>
|
|
2474
|
+
) : branches.local.map(name => (
|
|
2475
|
+
<div key={name} className="gbar-rowwrap">
|
|
2476
|
+
<button type="button" className={'gbar-row' + (name === branches.current ? ' gbar-cur' : '')} onClick={() => { pickBranch(name) }}>
|
|
2477
|
+
<span>{name}</span>
|
|
2478
|
+
{name === branches.current ? <span className="gbar-check" aria-hidden>✓</span> : null}
|
|
2479
|
+
</button>
|
|
2480
|
+
{name !== branches.current && !isProtectedBranch(name) ? (
|
|
2481
|
+
<button type="button" className={'gbar-del' + (confirmDelete === name ? ' gbar-arm' : '')} onClick={() => { deleteLocal(name) }} title={t('branchDelete')}>
|
|
2482
|
+
{confirmDelete === name ? t('branchDeleteConfirm') : '🗑'}
|
|
2483
|
+
</button>
|
|
2484
|
+
) : null}
|
|
2485
|
+
</div>
|
|
2486
|
+
))}
|
|
2487
|
+
<div className="gbar-sec">{t('branchRemote')}{branches !== null ? <span className="gbar-count">{branches.remote.length}</span> : null}</div>
|
|
2488
|
+
{branches?.remote.map(name => (
|
|
2489
|
+
<div key={name} className="gbar-rowwrap">
|
|
2490
|
+
<button type="button" className="gbar-row" onClick={() => { pickBranch(shortBranch(name)) }}>
|
|
2491
|
+
<span>{shortBranch(name)}</span>
|
|
2492
|
+
<span className="gbar-rm">{name.split('/')[0]}</span>
|
|
2493
|
+
</button>
|
|
2494
|
+
{!isProtectedBranch(shortBranch(name)) ? (
|
|
2495
|
+
<button type="button" className={'gbar-del' + (confirmDelete === name ? ' gbar-arm' : '')} onClick={() => { deleteRemote(name) }} title={t('branchRemoteDelete')}>
|
|
2496
|
+
{confirmDelete === name ? t('branchDeleteConfirm') : '🗑'}
|
|
2497
|
+
</button>
|
|
2498
|
+
) : null}
|
|
2499
|
+
</div>
|
|
2500
|
+
))}
|
|
2501
|
+
</div>
|
|
2502
|
+
</div>
|
|
2503
|
+
) : null}
|
|
2504
|
+
</span>
|
|
2505
|
+
{notice !== null ? createPortal(
|
|
2506
|
+
<div className={'gbar-notice gbar-' + notice.kind} role="status">{notice.text}</div>,
|
|
2507
|
+
document.body,
|
|
2508
|
+
) : null}
|
|
2509
|
+
</div>
|
|
2510
|
+
)
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* Mount the hero floating chip: watch for the workspace picker appearing
|
|
2515
|
+
* (hero mounts/unmounts as sessions open and close), keep a fixed-position
|
|
2516
|
+
* React root anchored just left of it, and re-render on settings changes.
|
|
2517
|
+
*/
|
|
2518
|
+
export function installHeroChip(controller: SettingsClient, t: Translate): () => void {
|
|
2519
|
+
const host = document.createElement('div')
|
|
2520
|
+
host.dataset.plugin = 'dsh-ui-tweaks-gitbar-hero'
|
|
2521
|
+
document.body.appendChild(host)
|
|
2522
|
+
const root = createRoot(host)
|
|
2523
|
+
let rect: { right: number; top: number } | null = null
|
|
2524
|
+
|
|
2525
|
+
const render = (): void => {
|
|
2526
|
+
const enabled = controller.getSnapshot().value?.gitBarEnabled ?? true
|
|
2527
|
+
root.render(rect === null || !enabled
|
|
2528
|
+
? null
|
|
2529
|
+
: <HeroBranchChip t={t} right={rect.right} top={rect.top} />)
|
|
2530
|
+
}
|
|
2531
|
+
const measure = (): void => {
|
|
2532
|
+
const el = document.querySelector<HTMLElement>(HERO_ANCHOR_SELECTOR)
|
|
2533
|
+
rect = el === null
|
|
2534
|
+
? null
|
|
2535
|
+
: (() => {
|
|
2536
|
+
const box = el.getBoundingClientRect()
|
|
2537
|
+
return { right: Math.max(0, Math.round(window.innerWidth - box.left + 8)), top: Math.round(box.top + box.height / 2) }
|
|
2538
|
+
})()
|
|
2539
|
+
render()
|
|
2540
|
+
}
|
|
2541
|
+
let raf = 0
|
|
2542
|
+
const observer = new MutationObserver(() => {
|
|
2543
|
+
cancelAnimationFrame(raf)
|
|
2544
|
+
raf = requestAnimationFrame(measure)
|
|
2545
|
+
})
|
|
2546
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
2547
|
+
window.addEventListener('resize', measure)
|
|
2548
|
+
const unsubscribe = controller.subscribe(render)
|
|
2549
|
+
measure()
|
|
2550
|
+
|
|
2551
|
+
return () => {
|
|
2552
|
+
observer.disconnect()
|
|
2553
|
+
cancelAnimationFrame(raf)
|
|
2554
|
+
window.removeEventListener('resize', measure)
|
|
2555
|
+
unsubscribe()
|
|
2556
|
+
root.render(null)
|
|
2557
|
+
host.remove()
|
|
2558
|
+
}
|
|
2559
|
+
}
|