dsh-better-sidebar 0.10.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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure helpers for the `browser.probe` route (sidebar browser): the host
3
+ * fetches the response HEADERS of a URL the user is browsing and the client
4
+ * decides whether the target site forbids being embedded (X-Frame-Options /
5
+ * CSP frame-ancestors are exactly the signals the browser enforces when it
6
+ * refuses an iframe load). Kept dependency-free so the parser is
7
+ * unit-testable.
8
+ */
9
+
10
+ /**
11
+ * Extract the `frame-ancestors` source list of a Content-Security-Policy
12
+ * header, or undefined when the directive is absent (or empty). The
13
+ * directive is the only one with a source list; sources are space-separated
14
+ * tokens (`'none'`, `'self'`, `*`, or origins).
15
+ */
16
+ export function extractFrameAncestors(csp: string | null): string[] | undefined {
17
+ if (csp === null) return undefined
18
+ for (const directive of csp.split(';')) {
19
+ const parts = directive.trim().split(/\s+/)
20
+ if (parts[0] === 'frame-ancestors') {
21
+ const sources = parts.slice(1).filter(source => source !== '')
22
+ return sources.length === 0 ? undefined : sources
23
+ }
24
+ }
25
+ return undefined
26
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Lazy chunk route: serves the client bundle's chunk scripts
3
+ * (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
4
+ * cannot serve arbitrary file names, so the plugin serves its own split
5
+ * bundles (lib/client-<name>.js) here; the client injects the script on
6
+ * first use of the feature that needs it (see src/client/chunk-loader.ts).
7
+ *
8
+ * Caching contract: every response carries `cache-control: no-cache` plus an
9
+ * ETag (content hash, memoized per file by mtime/size) and honors
10
+ * If-None-Match — the browser revalidates each fetch, but a 304 avoids
11
+ * re-downloading multi-MB chunks that did not change (page refresh, HMR
12
+ * re-activation). Same browser-trust fence as every other /sidebar route;
13
+ * only allowlisted chunk names are servable (no path traversal).
14
+ */
15
+ import { createHash } from 'node:crypto'
16
+ import { stat, readFile } from 'node:fs/promises'
17
+ import { dirname, join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import type { IncomingMessage, ServerResponse } from 'node:http'
20
+ import type { Context } from './context-types.ts'
21
+
22
+ /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
23
+ export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
24
+ export type ChunkName = (typeof CHUNK_NAMES)[number]
25
+
26
+ /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
27
+ const LIB_DIR = dirname(fileURLToPath(import.meta.url))
28
+
29
+ /** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
30
+ function shortHash(input: string | Buffer): string {
31
+ return createHash('sha1').update(input).digest('hex').slice(0, 12)
32
+ }
33
+
34
+ interface ChunkEtag {
35
+ mtimeMs: number
36
+ size: number
37
+ etag: string
38
+ }
39
+
40
+ /** ETag memo: recompute the content hash only when the file's stat changed. */
41
+ const etags = new Map<string, ChunkEtag>()
42
+
43
+ /**
44
+ * The chunk file's ETag (quoted hash), or undefined when the file is
45
+ * missing. Hash is recomputed only when mtime/size changed (hashing a
46
+ * multi-MB chunk per request is wasteful).
47
+ */
48
+ async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
49
+ const path = join(chunkDir, `client-${name}.js`)
50
+ const key = `${chunkDir}:${name}`
51
+ try {
52
+ const info = await stat(path)
53
+ const memo = etags.get(key)
54
+ if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
55
+ return memo.etag
56
+ }
57
+ const etag = `"${shortHash(await readFile(path))}"`
58
+ etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
59
+ return etag
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Build the /sidebar/bundle route handler. `fence` is the shared browser-
67
+ * trust check every /sidebar route applies; `chunkDir` is the directory the
68
+ * chunk scripts live in (overridable for tests).
69
+ */
70
+ export function createBundleRouteHandler(
71
+ fence: (req: IncomingMessage) => boolean,
72
+ chunkDir: string = LIB_DIR,
73
+ ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
74
+ return async (req, res): Promise<void> => {
75
+ if (!fence(req)) {
76
+ res.writeHead(403)
77
+ res.end('forbidden')
78
+ return
79
+ }
80
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
81
+ res.writeHead(405)
82
+ res.end()
83
+ return
84
+ }
85
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
86
+ const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
87
+ const name = match?.[1] as ChunkName | undefined
88
+ if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
89
+ res.writeHead(404)
90
+ res.end('not found')
91
+ return
92
+ }
93
+ const etag = await etagOf(name, chunkDir)
94
+ if (etag === undefined) {
95
+ // Registered name but unreadable (bundle not built yet): loud 404.
96
+ res.writeHead(404)
97
+ res.end('not found')
98
+ return
99
+ }
100
+ if (req.headers['if-none-match'] === etag) {
101
+ // Revalidation hit: unchanged chunk, no body — avoids re-downloading
102
+ // multi-MB scripts on page refresh / HMR re-activation.
103
+ res.writeHead(304, { 'cache-control': 'no-cache', etag })
104
+ res.end()
105
+ return
106
+ }
107
+ try {
108
+ const body = await readFile(join(chunkDir, `client-${name}.js`))
109
+ res.writeHead(200, {
110
+ 'content-type': 'text/javascript; charset=utf-8',
111
+ 'cache-control': 'no-cache',
112
+ etag,
113
+ })
114
+ res.end(body)
115
+ } catch {
116
+ // Read raced a delete/rebuild between the stat and the read.
117
+ res.writeHead(404)
118
+ res.end('not found')
119
+ }
120
+ }
121
+ }
122
+
123
+ /** Register the /sidebar/bundle route (disposed with the fiber). */
124
+ export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
125
+ return ctx.webServer.register({
126
+ kind: 'prefix',
127
+ path: '/sidebar/bundle',
128
+ handler: createBundleRouteHandler(fence),
129
+ })
130
+ }
@@ -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
+ IconRightUpOutline16,
26
+ IconWarningOutline16,
27
+ } from '@deepseek-ai/dsh-client-ui-primitives'
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
+ <IconRightUpOutline16 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 />
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
+ }