@keith9681/dsh-sidebar 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in browser tab: an address bar plus a sandboxed iframe.
|
|
3
|
+
*
|
|
4
|
+
* Security model (see browser.ts + the sandbox tokens below): the iframe is
|
|
5
|
+
* ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited
|
|
6
|
+
* page can never sit on the GUI's origin, read its storage, or reach
|
|
7
|
+
* /sidebar/api) and without `allow-top-navigation` (a page must not hijack
|
|
8
|
+
* the GUI). The address bar only accepts http(s) and refuses loopback /
|
|
9
|
+
* the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the
|
|
10
|
+
* sandbox attribute entirely for fully trusted sites — the visited page then
|
|
11
|
+
* runs with the GUI's own origin and full session access, so a persistent
|
|
12
|
+
* warning bar renders while it is off.
|
|
13
|
+
*
|
|
14
|
+
* The URL is persisted onto the tab (path/title via the patchTab reducer)
|
|
15
|
+
* so a reload restores the visited page; the back/forward stack only tracks
|
|
16
|
+
* address-bar navigations (in-frame link clicks are cross-origin and
|
|
17
|
+
* invisible — a documented limitation).
|
|
18
|
+
*/
|
|
19
|
+
import { useEffect, useState } from 'react'
|
|
20
|
+
import {
|
|
21
|
+
IconChevronLeftOutline14,
|
|
22
|
+
IconChevronRightOutline14,
|
|
23
|
+
IconLinkOutline14,
|
|
24
|
+
IconRefreshOutline14,
|
|
25
|
+
IconWarningOutline16,
|
|
26
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
27
|
+
import { VscLinkExternal } from 'react-icons/vsc'
|
|
28
|
+
import { api } from './api.ts'
|
|
29
|
+
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
|
|
30
|
+
import { patchTab } from './state.ts'
|
|
31
|
+
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
|
32
|
+
import { t } from './locales.ts'
|
|
33
|
+
import type { TabComponentProps } from './service.ts'
|
|
34
|
+
import css from './sidebar.module.css'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The browser iframe sandbox tokens. NO allow-same-origin (opaque origin —
|
|
38
|
+
* no GUI storage/API access), NO allow-top-navigation (a browsed page must
|
|
39
|
+
* not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals
|
|
40
|
+
* keep login flows working; allow-popups-to-escape-sandbox lets OAuth
|
|
41
|
+
* popups open as normal tabs (they are cross-origin to the GUI either way).
|
|
42
|
+
*/
|
|
43
|
+
export const BROWSER_IFRAME_SANDBOX =
|
|
44
|
+
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
|
|
45
|
+
|
|
46
|
+
export function BrowserView(props: TabComponentProps) {
|
|
47
|
+
const { store, tab } = props
|
|
48
|
+
// The current address (initialized from the persisted tab.path so a
|
|
49
|
+
// reload restores the visited page).
|
|
50
|
+
const [url, setUrl] = useState<string | undefined>(tab.path)
|
|
51
|
+
const [input, setInput] = useState<string>(tab.path ?? '')
|
|
52
|
+
/** Blocked/invalid hint shown under the address bar (null = none). */
|
|
53
|
+
const [message, setMessage] = useState<string | null>(null)
|
|
54
|
+
/** Address-bar navigation history (in-frame clicks are not tracked). */
|
|
55
|
+
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
|
|
56
|
+
const [cursor, setCursor] = useState<number>(tab.path !== undefined ? 0 : -1)
|
|
57
|
+
/** Bumped on reload to remount the iframe (also remounts on sandbox flip). */
|
|
58
|
+
const [reloadKey, setReloadKey] = useState(0)
|
|
59
|
+
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
|
|
60
|
+
* side card setting; lasts until the tab unmounts or the user restores). */
|
|
61
|
+
const [localUnlock, setLocalUnlock] = useState(false)
|
|
62
|
+
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
|
|
63
|
+
/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
|
|
64
|
+
* the probe verdict shown instead of the blank iframe. */
|
|
65
|
+
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
|
|
66
|
+
/** The user asked to load the refused site anyway (keeps the plain iframe). */
|
|
67
|
+
const [forceEmbed, setForceEmbed] = useState(false)
|
|
68
|
+
|
|
69
|
+
// Probe every navigation (address bar, history, restored path): when the
|
|
70
|
+
// target forbids embedding, show the reason + open-in-browser instead of
|
|
71
|
+
// the browser's cryptic "refused to connect" blank frame. A failed probe
|
|
72
|
+
// (unreachable) keeps the plain iframe.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (url === undefined) return
|
|
75
|
+
let cancelled = false
|
|
76
|
+
setEmbedBlocked(null)
|
|
77
|
+
setForceEmbed(false)
|
|
78
|
+
void api.browserProbe(url).then((probe) => {
|
|
79
|
+
if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url)
|
|
80
|
+
}).catch(() => { /* unreachable: keep the plain iframe */ })
|
|
81
|
+
return () => { cancelled = true }
|
|
82
|
+
}, [url])
|
|
83
|
+
|
|
84
|
+
const persist = (nextUrl: string): void => {
|
|
85
|
+
let host = nextUrl
|
|
86
|
+
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
|
|
87
|
+
store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host }))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const navigateTo = (raw: string): void => {
|
|
91
|
+
const result = normalizeBrowserUrl(raw, window.location.origin)
|
|
92
|
+
if (result.kind === 'ok') {
|
|
93
|
+
const next = result.url
|
|
94
|
+
setUrl(next)
|
|
95
|
+
setInput(next)
|
|
96
|
+
setMessage(null)
|
|
97
|
+
// Push onto the stack, dropping any stale forward entries.
|
|
98
|
+
setHistory(previous => [...previous.slice(0, cursor + 1), next])
|
|
99
|
+
setCursor(previous => previous + 1)
|
|
100
|
+
setReloadKey(key => key + 1)
|
|
101
|
+
persist(next)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
setMessage(result.kind === 'invalid'
|
|
105
|
+
? t('browserInvalid')
|
|
106
|
+
: result.reason === 'scheme' ? t('browserBlockedScheme')
|
|
107
|
+
: t('browserBlockedLoopback'))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const goBack = (): void => {
|
|
111
|
+
if (cursor <= 0) return
|
|
112
|
+
const next = history[cursor - 1]!
|
|
113
|
+
setCursor(cursor - 1)
|
|
114
|
+
setUrl(next)
|
|
115
|
+
setInput(next)
|
|
116
|
+
setReloadKey(key => key + 1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const goForward = (): void => {
|
|
120
|
+
if (cursor >= history.length - 1) return
|
|
121
|
+
const next = history[cursor + 1]!
|
|
122
|
+
setCursor(cursor + 1)
|
|
123
|
+
setUrl(next)
|
|
124
|
+
setInput(next)
|
|
125
|
+
setReloadKey(key => key + 1)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className={css.browser}>
|
|
130
|
+
<div className={css.browserBar}>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className={css.iconButton}
|
|
134
|
+
aria-label={t('browserBack')}
|
|
135
|
+
title={t('browserBack')}
|
|
136
|
+
disabled={cursor <= 0}
|
|
137
|
+
onClick={goBack}
|
|
138
|
+
>
|
|
139
|
+
<IconChevronLeftOutline14 />
|
|
140
|
+
</button>
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
className={css.iconButton}
|
|
144
|
+
aria-label={t('browserForward')}
|
|
145
|
+
title={t('browserForward')}
|
|
146
|
+
disabled={cursor >= history.length - 1}
|
|
147
|
+
onClick={goForward}
|
|
148
|
+
>
|
|
149
|
+
<IconChevronRightOutline14 />
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
className={css.iconButton}
|
|
154
|
+
aria-label={t('refresh')}
|
|
155
|
+
title={t('refresh')}
|
|
156
|
+
onClick={() => { setReloadKey(key => key + 1) }}
|
|
157
|
+
>
|
|
158
|
+
<IconRefreshOutline14 />
|
|
159
|
+
</button>
|
|
160
|
+
<input
|
|
161
|
+
className={css.browserInput}
|
|
162
|
+
value={input}
|
|
163
|
+
placeholder={t('browserPlaceholder')}
|
|
164
|
+
spellCheck={false}
|
|
165
|
+
onChange={event => { setInput(event.target.value) }}
|
|
166
|
+
onKeyDown={event => {
|
|
167
|
+
if (event.key === 'Enter') navigateTo(input)
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
className={css.iconButton}
|
|
173
|
+
aria-label={t('browserGo')}
|
|
174
|
+
title={t('browserGo')}
|
|
175
|
+
onClick={() => { navigateTo(input) }}
|
|
176
|
+
>
|
|
177
|
+
<IconLinkOutline14 />
|
|
178
|
+
</button>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className={css.iconButton}
|
|
182
|
+
aria-label={t('browserOpenExternal')}
|
|
183
|
+
title={t('browserOpenExternal')}
|
|
184
|
+
disabled={url === undefined}
|
|
185
|
+
onClick={() => {
|
|
186
|
+
if (url !== undefined) window.open(url, '_blank', 'noopener')
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
<VscLinkExternal size={15} />
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
{message !== null && <div className={css.browserMessage}>{message}</div>}
|
|
193
|
+
<SandboxStatusBar
|
|
194
|
+
sandboxed={!noSandbox}
|
|
195
|
+
local={localUnlock}
|
|
196
|
+
dangerCopy={t('browserNoSandboxWarning')}
|
|
197
|
+
onUnlock={() => { setLocalUnlock(true) }}
|
|
198
|
+
onRestore={() => { setLocalUnlock(false) }}
|
|
199
|
+
/>
|
|
200
|
+
{url === undefined ? (
|
|
201
|
+
<div className={css.browserStart}>{t('browserStart')}</div>
|
|
202
|
+
) : embedBlocked !== null && !forceEmbed ? (
|
|
203
|
+
<BrowserEmbedBlocked
|
|
204
|
+
url={embedBlocked}
|
|
205
|
+
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
|
|
206
|
+
onLoadAnyway={() => { setForceEmbed(true) }}
|
|
207
|
+
/>
|
|
208
|
+
) : (
|
|
209
|
+
<iframe
|
|
210
|
+
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
|
|
211
|
+
className={css.browserFrame}
|
|
212
|
+
src={url}
|
|
213
|
+
sandbox={noSandbox ? undefined : BROWSER_IFRAME_SANDBOX}
|
|
214
|
+
referrerPolicy="no-referrer"
|
|
215
|
+
allow=""
|
|
216
|
+
title={url}
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The embed-refusal panel: shown when the probed site forbids being
|
|
225
|
+
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
|
|
226
|
+
* iframe would only show the browser's "refused to connect" blank. Explains
|
|
227
|
+
* the reason and offers the real-browser open plus a load-anyway escape.
|
|
228
|
+
* Exported so the copy and the actions are testable without a DOM.
|
|
229
|
+
*/
|
|
230
|
+
export function BrowserEmbedBlocked(props: {
|
|
231
|
+
url: string
|
|
232
|
+
onOpenInBrowser: () => void
|
|
233
|
+
onLoadAnyway: () => void
|
|
234
|
+
}) {
|
|
235
|
+
const { url, onOpenInBrowser, onLoadAnyway } = props
|
|
236
|
+
let host = url
|
|
237
|
+
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
|
|
238
|
+
return (
|
|
239
|
+
<div className={css.browserBlocked}>
|
|
240
|
+
<IconWarningOutline16 size={16} />
|
|
241
|
+
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
|
|
242
|
+
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
|
|
243
|
+
<div className={css.browserBlockedActions}>
|
|
244
|
+
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
|
|
245
|
+
{t('browserOpenExternal')}
|
|
246
|
+
</button>
|
|
247
|
+
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
|
|
248
|
+
{t('browserEmbedAnyway')}
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diff tab: one change opened from the git panel, like VSCode's diff
|
|
3
|
+
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
|
|
4
|
+
* not; untracked files — which git diff never covers — render as a full-file
|
|
5
|
+
* addition from their content), a commit ref loads the commit's full patch
|
|
6
|
+
* (`git.show`-style). The header carries a refresh button because the tab
|
|
7
|
+
* stays mounted while the git panel's staging/discard operations change the
|
|
8
|
+
* very content it shows.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
11
|
+
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
12
|
+
import type { SessionScope } from './api.ts'
|
|
13
|
+
import { api } from './api.ts'
|
|
14
|
+
import type { SidebarDiffRef } from './state.ts'
|
|
15
|
+
import { DiffView } from './DiffView.tsx'
|
|
16
|
+
import { t } from './locales.ts'
|
|
17
|
+
import css from './sidebar.module.css'
|
|
18
|
+
|
|
19
|
+
/** The loaded diff surface (untracked content rendered as a full addition). */
|
|
20
|
+
interface DiffData {
|
|
21
|
+
diff: string
|
|
22
|
+
untracked?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
|
|
26
|
+
const { sessionId, cwd, diff } = props
|
|
27
|
+
const [loading, setLoading] = useState(true)
|
|
28
|
+
const [error, setError] = useState<string | null>(null)
|
|
29
|
+
const [data, setData] = useState<DiffData | null>(null)
|
|
30
|
+
const [tick, setTick] = useState(0)
|
|
31
|
+
|
|
32
|
+
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let cancelled = false
|
|
36
|
+
const scope: SessionScope = { sessionId, cwd }
|
|
37
|
+
setLoading(true)
|
|
38
|
+
setError(null)
|
|
39
|
+
setData(null)
|
|
40
|
+
const load = async (): Promise<void> => {
|
|
41
|
+
try {
|
|
42
|
+
if (diff.kind === 'commit') {
|
|
43
|
+
const result = await api.gitCommitDiff(scope, diff.hashFull)
|
|
44
|
+
if (!cancelled) setData({ diff: result.diff })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
let result = await api.gitDiff(scope, diff.path, diff.staged)
|
|
48
|
+
if (result.diff === '') {
|
|
49
|
+
// The requested side is empty — try the OTHER side once: the ref
|
|
50
|
+
// may predate the staged-flag fix, or the change moved sides (a
|
|
51
|
+
// file staged after its tab opened). Both sides empty means the
|
|
52
|
+
// file genuinely has no text changes.
|
|
53
|
+
const other = await api.gitDiff(scope, diff.path, !diff.staged)
|
|
54
|
+
if (other.diff !== '') result = other
|
|
55
|
+
}
|
|
56
|
+
if (result.diff !== '') {
|
|
57
|
+
if (!cancelled) setData({ diff: result.diff })
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
// Empty diff: an untracked file (git diff never lists it) falls back
|
|
61
|
+
// to a full-file addition; anything else is a genuine no-text-change.
|
|
62
|
+
if (diff.untracked === true && !diff.staged) {
|
|
63
|
+
const text = await api.fsRead(scope, diff.path)
|
|
64
|
+
if (!cancelled) {
|
|
65
|
+
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
|
|
66
|
+
}
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (!cancelled) setData({ diff: '' })
|
|
70
|
+
} catch (reason) {
|
|
71
|
+
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
|
|
72
|
+
} finally {
|
|
73
|
+
if (!cancelled) setLoading(false)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
void load()
|
|
77
|
+
return () => { cancelled = true }
|
|
78
|
+
}, [sessionId, cwd, diff, tick])
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className={css.gitDiffTab}>
|
|
82
|
+
<div className={css.gitDiffTabHeader}>
|
|
83
|
+
<span className={css.gitDiffTabTitle} title={diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}>
|
|
84
|
+
{diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}
|
|
85
|
+
</span>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className={css.iconButton}
|
|
89
|
+
aria-label={t('refresh')}
|
|
90
|
+
title={t('refresh')}
|
|
91
|
+
onClick={refresh}
|
|
92
|
+
>
|
|
93
|
+
<IconRefreshOutline16 size={14} />
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
|
|
97
|
+
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
|
|
98
|
+
{!loading && error === null && data !== null && (
|
|
99
|
+
<>
|
|
100
|
+
{data.untracked !== undefined
|
|
101
|
+
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
|
|
102
|
+
: <DiffView diff={data.diff} />}
|
|
103
|
+
{data.diff === '' && data.untracked === undefined && (
|
|
104
|
+
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
|
|
105
|
+
)}
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real diff surface for the git panel: parses the host's unified diff
|
|
3
|
+
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
|
|
4
|
+
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
|
|
5
|
+
* gutters, and aligned context / deleted / added rows colored through the
|
|
6
|
+
* DSH tokens. Untracked files produce no `git diff` output, so the caller
|
|
7
|
+
* can pass the file content to render as a full-file addition instead.
|
|
8
|
+
*
|
|
9
|
+
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
|
|
10
|
+
* cases are unit-tested without a DOM.
|
|
11
|
+
*/
|
|
12
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
13
|
+
import clsx from 'clsx'
|
|
14
|
+
import { t } from './locales.ts'
|
|
15
|
+
import css from './sidebar.module.css'
|
|
16
|
+
|
|
17
|
+
/** One rendered diff line. */
|
|
18
|
+
export interface DiffLine {
|
|
19
|
+
kind: 'ctx' | 'del' | 'add' | 'meta'
|
|
20
|
+
/** The line content without its diff marker ('' for the no-newline marker). */
|
|
21
|
+
text: string
|
|
22
|
+
/** Old-side line number (null for pure additions / metadata). */
|
|
23
|
+
oldNum: number | null
|
|
24
|
+
/** New-side line number (null for pure deletions / metadata). */
|
|
25
|
+
newNum: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One parsed hunk. */
|
|
29
|
+
export interface DiffHunk {
|
|
30
|
+
/** The old-side start line (`-a[,b]`). */
|
|
31
|
+
oldStart: number
|
|
32
|
+
/** The new-side start line (`+c[,d]`). */
|
|
33
|
+
newStart: number
|
|
34
|
+
/** The section text after the trailing `@@` (may be empty). */
|
|
35
|
+
header: string
|
|
36
|
+
lines: DiffLine[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One parsed file section of a unified diff. */
|
|
40
|
+
export interface DiffFile {
|
|
41
|
+
/** The `---` path verbatim ('/dev/null' for a new file). */
|
|
42
|
+
oldPath: string
|
|
43
|
+
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
|
|
44
|
+
newPath: string
|
|
45
|
+
/** The file changed with binary content: no hunks to draw. */
|
|
46
|
+
binary: boolean
|
|
47
|
+
hunks: DiffHunk[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The parsed unified diff. */
|
|
51
|
+
export interface ParsedDiff {
|
|
52
|
+
files: DiffFile[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
|
|
56
|
+
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
|
|
57
|
+
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
|
|
58
|
+
if (match === null) return null
|
|
59
|
+
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse `git diff --no-color` output into file sections and hunks. Rows
|
|
64
|
+
* outside a file section (leading noise) and metadata rows between the
|
|
65
|
+
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
|
|
66
|
+
* changes, rename/similarity lines) are skipped; a section that never
|
|
67
|
+
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
|
|
68
|
+
* can still draw its path.
|
|
69
|
+
*/
|
|
70
|
+
export function parseUnifiedDiff(text: string): ParsedDiff {
|
|
71
|
+
const files: DiffFile[] = []
|
|
72
|
+
let current: DiffFile | null = null
|
|
73
|
+
let inHunk = false
|
|
74
|
+
let hunk: DiffHunk | null = null
|
|
75
|
+
let oldNum = 0
|
|
76
|
+
let newNum = 0
|
|
77
|
+
const flushHunk = (): void => {
|
|
78
|
+
if (current !== null && hunk !== null) current.hunks.push(hunk)
|
|
79
|
+
hunk = null
|
|
80
|
+
inHunk = false
|
|
81
|
+
}
|
|
82
|
+
for (const raw of text.split('\n')) {
|
|
83
|
+
if (raw.startsWith('diff --git ')) {
|
|
84
|
+
flushHunk()
|
|
85
|
+
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
|
|
86
|
+
files.push(current)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (current === null) continue
|
|
90
|
+
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
|
|
91
|
+
flushHunk()
|
|
92
|
+
current.binary = true
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (raw.startsWith('--- ')) {
|
|
96
|
+
flushHunk()
|
|
97
|
+
current.oldPath = raw.slice(4)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (raw.startsWith('+++ ')) {
|
|
101
|
+
current.newPath = raw.slice(4)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
const header = parseHunkHeader(raw)
|
|
105
|
+
if (header !== null) {
|
|
106
|
+
flushHunk()
|
|
107
|
+
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
|
|
108
|
+
oldNum = header.oldStart
|
|
109
|
+
newNum = header.newStart
|
|
110
|
+
inHunk = true
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (!inHunk || hunk === null) continue
|
|
114
|
+
const marker = raw[0]
|
|
115
|
+
if (marker === '\\') {
|
|
116
|
+
// ``: metadata attached to the previous row.
|
|
117
|
+
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (marker === ' ') {
|
|
121
|
+
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
|
|
122
|
+
oldNum += 1
|
|
123
|
+
newNum += 1
|
|
124
|
+
} else if (marker === '-') {
|
|
125
|
+
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
|
|
126
|
+
oldNum += 1
|
|
127
|
+
} else if (marker === '+') {
|
|
128
|
+
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
|
|
129
|
+
newNum += 1
|
|
130
|
+
} else {
|
|
131
|
+
// Not a diff line (a hunk can never contain one): stop the hunk.
|
|
132
|
+
flushHunk()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
flushHunk()
|
|
136
|
+
return { files }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Build the untracked-file shape: one file, one hunk of pure additions. */
|
|
140
|
+
function untrackedFile(path: string, content: string): DiffFile {
|
|
141
|
+
const lines: DiffLine[] = []
|
|
142
|
+
const body = content.endsWith('\n') ? content.slice(0, -1) : content
|
|
143
|
+
if (body !== '') {
|
|
144
|
+
let num = 1
|
|
145
|
+
for (const line of body.split('\n')) {
|
|
146
|
+
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
|
|
147
|
+
num += 1
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
|
|
154
|
+
function displayPath(path: string): string {
|
|
155
|
+
if (path === '/dev/null') return path
|
|
156
|
+
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
|
|
157
|
+
return path
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
|
|
161
|
+
function fileTag(file: DiffFile): string | null {
|
|
162
|
+
if (file.binary) return t('diffBinary')
|
|
163
|
+
if (file.oldPath === '/dev/null') return t('diffAdded')
|
|
164
|
+
if (file.newPath === '/dev/null') return t('diffDeleted')
|
|
165
|
+
const oldPath = displayPath(file.oldPath)
|
|
166
|
+
const newPath = displayPath(file.newPath)
|
|
167
|
+
if (oldPath !== newPath) return t('diffRenamed')
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
|
|
172
|
+
const MAX_DIFF_ROWS = 500
|
|
173
|
+
|
|
174
|
+
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i
|
|
175
|
+
const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(?:\.[^/]*)?$/i
|
|
176
|
+
const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i
|
|
177
|
+
const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i
|
|
178
|
+
|
|
179
|
+
/** Source files open by default; tests, docs, generated files and unknown types stay folded. */
|
|
180
|
+
function defaultExpandedFiles(files: DiffFile[]): Set<number> {
|
|
181
|
+
const expanded = new Set<number>()
|
|
182
|
+
files.forEach((file, index) => {
|
|
183
|
+
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
|
184
|
+
if (!file.binary && file.hunks.length > 0
|
|
185
|
+
&& !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path)
|
|
186
|
+
&& SOURCE_PATH.test(path)) {
|
|
187
|
+
expanded.add(index)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
return expanded
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface DiffViewProps {
|
|
194
|
+
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
|
|
195
|
+
diff: string
|
|
196
|
+
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
|
|
197
|
+
untrackedPath?: string
|
|
198
|
+
untrackedContent?: string
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
|
|
202
|
+
const parsed = useMemo<ParsedDiff>(() => {
|
|
203
|
+
if (untrackedPath !== undefined) {
|
|
204
|
+
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
|
|
205
|
+
}
|
|
206
|
+
return parseUnifiedDiff(diff)
|
|
207
|
+
}, [diff, untrackedPath, untrackedContent])
|
|
208
|
+
const [expanded, setExpanded] = useState(false)
|
|
209
|
+
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
|
|
210
|
+
|
|
211
|
+
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
|
|
212
|
+
|
|
213
|
+
// Flatten into display rows so the cap can slice a single list.
|
|
214
|
+
const rows = useMemo(() => {
|
|
215
|
+
const out: Array<{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
|
216
|
+
parsed.files.forEach((file, fileIndex) => {
|
|
217
|
+
out.push({ key: `f${fileIndex}`, file, fileIndex, type: 'path' })
|
|
218
|
+
if (file.binary || !expandedFiles.has(fileIndex)) return
|
|
219
|
+
file.hunks.forEach((hunk, hunkIndex) => {
|
|
220
|
+
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, fileIndex, type: 'hunk', hunk })
|
|
221
|
+
hunk.lines.forEach((line, lineIndex) => {
|
|
222
|
+
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, fileIndex, type: 'line', hunk, line })
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
return out
|
|
227
|
+
}, [parsed, expandedFiles])
|
|
228
|
+
|
|
229
|
+
const hidden = rows.length - MAX_DIFF_ROWS
|
|
230
|
+
const capped = hidden > 0 && !expanded
|
|
231
|
+
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
|
|
232
|
+
const tailLines = MAX_DIFF_ROWS - headLines
|
|
233
|
+
const head = capped ? rows.slice(0, headLines) : rows
|
|
234
|
+
const tail = capped ? rows.slice(rows.length - tailLines) : []
|
|
235
|
+
|
|
236
|
+
if (rows.length === 0) return null
|
|
237
|
+
|
|
238
|
+
const renderRow = (row: (typeof rows)[number]): ReactNode => {
|
|
239
|
+
if (row.type === 'path') {
|
|
240
|
+
const tag = fileTag(row.file)
|
|
241
|
+
const from = displayPath(row.file.oldPath)
|
|
242
|
+
const to = displayPath(row.file.newPath)
|
|
243
|
+
const expandable = !row.file.binary && row.file.hunks.length > 0
|
|
244
|
+
const fileExpanded = expandedFiles.has(row.fileIndex)
|
|
245
|
+
return (
|
|
246
|
+
<button
|
|
247
|
+
key={row.key}
|
|
248
|
+
type="button"
|
|
249
|
+
className={css.gitDiffFile}
|
|
250
|
+
disabled={!expandable}
|
|
251
|
+
aria-expanded={expandable ? fileExpanded : undefined}
|
|
252
|
+
onClick={() => {
|
|
253
|
+
setExpandedFiles(current => {
|
|
254
|
+
const next = new Set(current)
|
|
255
|
+
if (next.has(row.fileIndex)) next.delete(row.fileIndex)
|
|
256
|
+
else next.add(row.fileIndex)
|
|
257
|
+
return next
|
|
258
|
+
})
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
{expandable && <span aria-hidden="true" className={clsx(css.gitDiffFileChevron, fileExpanded && css.gitDiffFileChevronExpanded)}>›</span>}
|
|
262
|
+
<span className={css.gitDiffFilePath}>{to}</span>
|
|
263
|
+
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
|
|
264
|
+
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
|
|
265
|
+
</button>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
if (row.type === 'hunk') {
|
|
269
|
+
const hunk = row.hunk!
|
|
270
|
+
return (
|
|
271
|
+
<div key={row.key} className={css.gitDiffHunk}>
|
|
272
|
+
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
|
|
273
|
+
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
const line = row.line!
|
|
278
|
+
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
|
|
279
|
+
return (
|
|
280
|
+
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
|
|
281
|
+
{line.kind === 'meta'
|
|
282
|
+
? <span className={css.gitDiffMetaText}>{line.text}</span>
|
|
283
|
+
: (
|
|
284
|
+
<>
|
|
285
|
+
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
|
|
286
|
+
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
|
|
287
|
+
<span className={css.gitDiffCode}>{line.text}</span>
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div className={css.gitDiff}>
|
|
296
|
+
{head.map(renderRow)}
|
|
297
|
+
{hidden > 0 && (
|
|
298
|
+
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
|
|
299
|
+
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
|
|
300
|
+
</button>
|
|
301
|
+
)}
|
|
302
|
+
{tail.map(renderRow)}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|