dsh-vscode-workbench 0.1.14 → 0.2.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/README.en.md +4 -0
- package/README.md +8 -2
- package/lib/client.js +5014 -674
- package/lib/client.js.map +1 -1
- package/lib/index.js +832 -80
- package/lib/index.js.map +1 -1
- package/lib/types/client/ActivityBar.d.ts +24 -0
- package/lib/types/client/DshConversationPanel.d.ts +1 -1
- package/lib/types/client/FileIcon.d.ts +8 -0
- package/lib/types/client/Icon.d.ts +1 -3
- package/lib/types/client/MonacoEditor.d.ts +36 -0
- package/lib/types/client/Workbench.d.ts +19 -7
- package/lib/types/client/WorkspaceSwitcher.d.ts +13 -0
- package/lib/types/client/api.d.ts +2 -0
- package/lib/types/client/documentPreview.d.ts +10 -2
- package/lib/types/client/dshComposer.d.ts +21 -0
- package/lib/types/client/dshNavigation.d.ts +46 -0
- package/lib/types/client/fileIconMap.d.ts +2 -0
- package/lib/types/client/gitError.d.ts +2 -0
- package/lib/types/client/lsp.d.ts +27 -0
- package/lib/types/client/searchHighlight.d.ts +5 -0
- package/lib/types/client/typescriptProject.d.ts +20 -0
- package/lib/types/context.d.ts +15 -0
- package/lib/types/host.d.ts +37 -0
- package/lib/types/lsp.d.ts +54 -0
- package/lib/types/office.d.ts +17 -0
- package/lib/types/types.d.ts +5 -0
- package/lib/types/watch-ignore.d.ts +4 -0
- package/package.json +5 -2
- package/src/client/ActivityBar.tsx +152 -0
- package/src/client/DshConversationPanel.tsx +15 -12
- package/src/client/FileIcon.tsx +6 -0
- package/src/client/Icon.tsx +1 -14
- package/src/client/MonacoEditor.tsx +187 -19
- package/src/client/TerminalPanel.tsx +6 -3
- package/src/client/Workbench.tsx +324 -81
- package/src/client/WorkspaceSwitcher.tsx +53 -0
- package/src/client/api.ts +27 -1
- package/src/client/documentPreview.tsx +92 -5
- package/src/client/dshComposer.ts +73 -0
- package/src/client/dshNavigation.ts +81 -0
- package/src/client/fileIconMap.ts +7 -0
- package/src/client/gitError.ts +5 -0
- package/src/client/index.tsx +28 -14
- package/src/client/lsp.ts +300 -0
- package/src/client/searchHighlight.ts +6 -0
- package/src/client/styles.ts +28 -31
- package/src/client/typescriptProject.ts +81 -0
- package/src/context.ts +8 -0
- package/src/host.ts +100 -15
- package/src/index.ts +121 -17
- package/src/lsp.ts +170 -0
- package/src/office.ts +95 -0
- package/src/types.ts +2 -0
- package/src/watch-ignore.ts +22 -1
package/src/client/Workbench.tsx
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import type { CSSProperties, DragEvent, ReactNode } from 'react'
|
|
3
|
-
import type { FileDocument, FileEntry, GitChange, GitCommit, GitCommitFile, GitDiff, GitStatus, SearchMatch } from '../types.ts'
|
|
3
|
+
import type { FileDocument, FileEntry, GitBranch, GitChange, GitCommit, GitCommitFile, GitDiff, GitStatus, SearchMatch } from '../types.ts'
|
|
4
4
|
import { ApiError, api } from './api.ts'
|
|
5
5
|
import type { VscodeWorkbench, WorkbenchView } from './service.ts'
|
|
6
|
-
import {
|
|
6
|
+
import { Icon } from './Icon.tsx'
|
|
7
|
+
import { FileIcon } from './FileIcon.tsx'
|
|
8
|
+
import { branchErrorMessage } from './gitError.ts'
|
|
7
9
|
import { MarkdownPreview, markdownHeadings } from './MarkdownPreview.tsx'
|
|
8
10
|
import type { MarkdownHeading } from './MarkdownPreview.tsx'
|
|
9
|
-
import { MonacoDiffEditor, MonacoEditor } from './MonacoEditor.tsx'
|
|
11
|
+
import { MonacoDiffEditor, MonacoEditor, loadMonaco, preloadMonaco, retainEditorModels } from './MonacoEditor.tsx'
|
|
10
12
|
import { DshConversationPanel, DshConversationTheme } from './DshConversationPanel.tsx'
|
|
11
13
|
import { DshMark } from './DshMark.tsx'
|
|
14
|
+
import { ActivityBar, normalizeActivityOrder, type ActivityView } from './ActivityBar.tsx'
|
|
15
|
+
import { WorkspaceSwitcher } from './WorkspaceSwitcher.tsx'
|
|
16
|
+
import type { DshNavigation, WorkspaceChoice } from './dshNavigation.ts'
|
|
17
|
+
import { LspManager, languageServerIdForPath, type LspServerStatus } from './lsp.ts'
|
|
18
|
+
import { TS_INTELLISENSE_KEY, disposeTypeScriptProject, indexTypeScriptProject, refreshIndexedFiles, tsIntellisenseEnabled, workspacePathFromModel } from './typescriptProject.ts'
|
|
19
|
+
import { ADD_TO_DSH_SHORTCUT, dshReference, insertIntoDshComposer, isAddToDshShortcut } from './dshComposer.ts'
|
|
12
20
|
import { DocumentPreview, documentPreviewKind, type DocumentPreviewKind } from './documentPreview.tsx'
|
|
13
21
|
import { toggleTerminal } from './shortcuts.ts'
|
|
14
22
|
import { websocketUrl } from './websocket.ts'
|
|
15
23
|
import { resetWorkspaceDocuments, WorkspaceRequestScope } from './workspaceState.ts'
|
|
16
24
|
|
|
17
|
-
interface Tab extends FileDocument { instance?: symbol; dirty: boolean; draft: string; loaded?: boolean; line?: number; column?: number; external?: boolean; preview?: boolean; title?: string; diff?: GitDiff; diffMode?: 'inline' | 'split'; diffSourcePath?:string; imageUrl?:string; documentPreview?:DocumentPreviewKind; documentUrl?:string }
|
|
25
|
+
interface Tab extends FileDocument { instance?: symbol; dirty: boolean; draft: string; loaded?: boolean; line?: number; column?: number; highlight?: string; external?: boolean; preview?: boolean; title?: string; diff?: GitDiff; diffMode?: 'inline' | 'split'; diffSourcePath?:string; imageUrl?:string; documentPreview?:DocumentPreviewKind; documentUrl?:string }
|
|
18
26
|
type WorkbenchTheme = 'dsh' | 'dark' | 'light'
|
|
19
|
-
interface TreeProps { sessionId: string; path: string; depth: number; expanded: Set<string>; selected?: string; selectedDirectory: string; statuses: Map<string, string>; onToggle(path: string): void; onSelectDirectory(path: string): void; onOpen(path: string): void; onChange(): void; onUpload(directory: string, files: FileList): void }
|
|
27
|
+
interface TreeProps { sessionId: string; path: string; depth: number; expanded: Set<string>; selected?: string; selectedDirectory: string; statuses: Map<string, string>; onToggle(path: string): void; onSelectDirectory(path: string): void; onOpen(path: string): void; onChange(): void; onUpload(directory: string, files: FileList): void; onAddToDsh(path: string, directory: boolean): void }
|
|
20
28
|
interface CreatingEntry { kind: 'file' | 'directory'; directory: string; value: string; error?: string }
|
|
21
29
|
|
|
22
30
|
type ResizeDirection = 1 | -1
|
|
@@ -26,13 +34,7 @@ export function resizedPanelWidth(startWidth: number, startX: number, nextX: num
|
|
|
26
34
|
return Math.max(min, Math.min(max, Math.round(startWidth + (nextX - startX) * direction)))
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
export function workspaceRetryDelay(error: unknown, attempt: number): number | undefined {
|
|
31
|
-
if (!(error instanceof ApiError) || error.code !== 'session-not-found' || attempt >= 10) return undefined
|
|
32
|
-
return Math.min(250 * (attempt + 1), 1000)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function PanelResizer({ className, label, width, min, max, defaultWidth, direction, onWidth }: { className: string; label: string; width: number; min: number; max: number; defaultWidth: number; direction: ResizeDirection; onWidth(width: number): void }) {
|
|
37
|
+
function PanelResizer({ className, label, width, min, max, defaultWidth, direction, hideBelow, onHide, onWidth }: { className: string; label: string; width: number; min: number; max: number; defaultWidth: number; direction: ResizeDirection; hideBelow?: number; onHide?(): void; onWidth(width: number): void }) {
|
|
36
38
|
const [dragging, setDragging] = useState(false)
|
|
37
39
|
const stopDragging = useRef<() => void>(() => {})
|
|
38
40
|
useEffect(() => () => stopDragging.current(), [])
|
|
@@ -67,7 +69,9 @@ function PanelResizer({ className, label, width, min, max, defaultWidth, directi
|
|
|
67
69
|
setDragging(false)
|
|
68
70
|
stopDragging.current = () => {}
|
|
69
71
|
if (!commit) return
|
|
70
|
-
|
|
72
|
+
const nextWidth = Math.round(startWidth + (nextX - startX) * direction)
|
|
73
|
+
if (hideBelow !== undefined && nextWidth < hideBelow) { onHide?.(); return }
|
|
74
|
+
onWidth(Math.max(min, Math.min(max, nextWidth)))
|
|
71
75
|
}
|
|
72
76
|
const up = (next: PointerEvent) => cleanup(true, next.clientX)
|
|
73
77
|
const cancel = () => cleanup()
|
|
@@ -94,6 +98,9 @@ export function directoryAffected(directory:string,changedPaths:string[]):boolea
|
|
|
94
98
|
/** Return the basename shown in a tab while preserving its full internal path. */
|
|
95
99
|
export function tabLabel(tab:Pick<Tab,'path'|'title'|'diffSourcePath'>):string{const path=tab.title??tab.diffSourcePath??tab.path.replace(/^diff:/,'');return path.split(/[\\/]/).at(-1)??path}
|
|
96
100
|
|
|
101
|
+
function readActivityJSON(key: string): unknown { try { return JSON.parse(localStorage.getItem(key) ?? '[]') } catch { return [] } }
|
|
102
|
+
function readActivityHidden(id: string): boolean { return false }
|
|
103
|
+
|
|
97
104
|
/** Return the horizontal scroll offset that keeps an active tab inside the tab strip. */
|
|
98
105
|
export function tabScrollLeft(scrollLeft:number,viewport:{left:number;right:number},tab:{left:number;right:number}):number{
|
|
99
106
|
if(tab.left<viewport.left)return scrollLeft-(viewport.left-tab.left)
|
|
@@ -116,7 +123,62 @@ function useDshDarkTheme(): boolean {
|
|
|
116
123
|
return dark
|
|
117
124
|
}
|
|
118
125
|
|
|
119
|
-
|
|
126
|
+
/** Workspace-relative directory that would contain `path`. */
|
|
127
|
+
export function parentPath(path: string): string { return path.split('/').slice(0, -1).join('/') }
|
|
128
|
+
|
|
129
|
+
/** Destination path when moving an entry into `directory`. */
|
|
130
|
+
export function movedPath(path: string, directory: string): string { return createdEntryPath(directory, path.split('/').at(-1) ?? path) }
|
|
131
|
+
|
|
132
|
+
/** Reject moves into the same folder, into the entry itself, or into its descendants. */
|
|
133
|
+
export function canMoveTo(path: string, directory: string): boolean { return parentPath(path) !== directory && directory !== path && !directory.startsWith(`${path}/`) }
|
|
134
|
+
|
|
135
|
+
/** Chromium `DownloadURL` payload that lets a file be dropped onto the desktop or another app. */
|
|
136
|
+
export function downloadUrlData(sessionId: string, path: string, origin: string): string {
|
|
137
|
+
const name = path.split('/').at(-1) ?? path
|
|
138
|
+
return `application/octet-stream:${name}:${origin}/dsh-vscode/download?${new URLSearchParams({ sessionId, path })}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ENTRY_DRAG_TYPE = 'application/x-dvw-entry'
|
|
142
|
+
const DRAG_EXPAND_DELAY = 500
|
|
143
|
+
// Only one Explorer drag can be active; dragover cannot read dataTransfer payloads, so keep the entry here.
|
|
144
|
+
let draggedEntry: FileEntry | undefined
|
|
145
|
+
let moveTarget: HTMLElement | undefined
|
|
146
|
+
let expandTimer: ReturnType<typeof setTimeout> | undefined
|
|
147
|
+
|
|
148
|
+
function clearMoveTarget() { clearTimeout(expandTimer); expandTimer = undefined; moveTarget?.removeAttribute('data-dvw-move-target'); moveTarget = undefined }
|
|
149
|
+
function markMoveTarget(element: HTMLElement | null | undefined, expand?: () => void) {
|
|
150
|
+
if (element === moveTarget) return
|
|
151
|
+
clearMoveTarget()
|
|
152
|
+
if (!element) return
|
|
153
|
+
moveTarget = element; element.dataset.dvwMoveTarget = 'true'
|
|
154
|
+
if (expand) expandTimer = setTimeout(expand, DRAG_EXPAND_DELAY)
|
|
155
|
+
}
|
|
156
|
+
function startEntryDrag(event: DragEvent, sessionId: string, entry: FileEntry) {
|
|
157
|
+
draggedEntry = entry
|
|
158
|
+
event.dataTransfer.effectAllowed = 'copyMove'
|
|
159
|
+
event.dataTransfer.setData(ENTRY_DRAG_TYPE, entry.path)
|
|
160
|
+
event.dataTransfer.setData('text/plain', entry.path)
|
|
161
|
+
if (entry.kind === 'file') event.dataTransfer.setData('DownloadURL', downloadUrlData(sessionId, entry.path, location.origin))
|
|
162
|
+
}
|
|
163
|
+
function endEntryDrag() { draggedEntry = undefined; clearMoveTarget() }
|
|
164
|
+
/** Accept an Explorer drag over `element` when the entry may move into `directory`; returns false for non-Explorer drags. */
|
|
165
|
+
function entryDragOver(event: DragEvent, directory: string, element: HTMLElement | null | undefined, expand?: () => void): boolean {
|
|
166
|
+
if (draggedEntry === undefined) return false
|
|
167
|
+
event.stopPropagation()
|
|
168
|
+
if (!canMoveTo(draggedEntry.path, directory)) { clearMoveTarget(); event.dataTransfer.dropEffect = 'none'; return true }
|
|
169
|
+
event.preventDefault(); event.dataTransfer.dropEffect = 'move'; markMoveTarget(element, expand)
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
async function dropEntry(event: DragEvent, sessionId: string, directory: string, onMoved: () => void) {
|
|
173
|
+
const entry = draggedEntry
|
|
174
|
+
if (entry === undefined) return
|
|
175
|
+
event.preventDefault(); event.stopPropagation(); endEntryDrag()
|
|
176
|
+
if (!canMoveTo(entry.path, directory)) return
|
|
177
|
+
const nextPath = movedPath(entry.path, directory)
|
|
178
|
+
try { await api('fs.rename', { sessionId, path: entry.path, nextPath }); onMoved(); window.dispatchEvent(new CustomEvent('dvw-files-changed', { detail: [entry.path, nextPath] })) } catch (error) { alert(error instanceof Error ? error.message : String(error)) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function Tree({ sessionId, path, depth, expanded, selected, selectedDirectory, statuses, onToggle, onSelectDirectory, onOpen, onChange, onUpload, onAddToDsh, creating, onCreatingChange, onCreatingCancel, onCreatingSubmit }: TreeProps & { creating?: CreatingEntry; onCreatingChange(value: string): void; onCreatingCancel(): void; onCreatingSubmit(): void }) {
|
|
120
182
|
const [rows, setRows] = useState<FileEntry[]>([]); const [error, setError] = useState<string>(); const [dropTarget, setDropTarget] = useState(false); const [context, setContext] = useState<{ entry: FileEntry; x: number; y: number }>(); const [renaming, setRenaming] = useState<{ path: string; value: string }>(); const [pendingDelete, setPendingDelete] = useState<FileEntry>()
|
|
121
183
|
const load = useCallback(() => { void api<FileEntry[]>('fs.list', { sessionId, path }).then(value => { setRows(value); setError(undefined) }).catch(error => setError(String(error))) }, [sessionId, path])
|
|
122
184
|
useEffect(load, [load])
|
|
@@ -127,16 +189,21 @@ function Tree({ sessionId, path, depth, expanded, selected, selectedDirectory, s
|
|
|
127
189
|
useEffect(() => { if (pendingDelete === undefined) return; const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') setPendingDelete(undefined) }; window.addEventListener('keydown', escape); return () => window.removeEventListener('keydown', escape) }, [pendingDelete])
|
|
128
190
|
const rename = async (entry: FileEntry) => { const value = renaming?.path === entry.path ? renaming.value.trim() : ''; setRenaming(undefined); if (value === '' || value === entry.name) return; try { await api('fs.rename', { sessionId, path: entry.path, nextPath: renamedPath(entry.path, value) }); load(); onChange() } catch (error) { alert(error instanceof Error ? error.message : String(error)) } }
|
|
129
191
|
const remove = async () => { if (pendingDelete === undefined) return; const entry = pendingDelete; setPendingDelete(undefined); try { await api('fs.delete', { sessionId, path: entry.path }); load(); onChange() } catch (error) { alert(error instanceof Error ? error.message : String(error)) } }
|
|
192
|
+
const entryDirectory = (row: FileEntry) => row.kind === 'directory' ? row.path : parentPath(row.path)
|
|
193
|
+
// A folder highlights its whole block like VS Code; a file targets its parent folder's block (or the tree root).
|
|
194
|
+
const entryTarget = (event: DragEvent, row: FileEntry) => { const entry = (event.currentTarget as HTMLElement).parentElement; return row.kind === 'directory' ? entry : entry?.parentElement?.closest<HTMLElement>('.dvw-tree-entry,.dvw-tree') }
|
|
130
195
|
const drop = (event: DragEvent, directory: string) => { event.preventDefault(); event.stopPropagation(); setDropTarget(false); if (event.dataTransfer.files.length > 0) onUpload(directory, event.dataTransfer.files) }
|
|
131
|
-
return <>{error && <div className="dvw-error">{error}</div>}{creating?.directory === path && <><div className="dvw-tree-row dvw-tree-create-row" style={{ paddingLeft: 6 + depth * 13 }}><span className="dvw-chevron"/><Icon name={creating.kind === 'directory' ? 'folder' : 'new-file'} className={`dvw-file-icon ${creating.kind === 'directory' ? 'folder' : ''}`}/><input className="dvw-tree-create-input" value={creating.value} autoFocus aria-label={creating.kind === 'directory' ? '新建文件夹名称' : '新建文件名称'} aria-invalid={creating.error !== undefined} placeholder={creating.kind === 'directory' ? '文件夹名称' : '文件名称'} onChange={event => onCreatingChange(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); onCreatingSubmit() } if (event.key === 'Escape') { event.preventDefault(); onCreatingCancel() } }} onBlur={onCreatingCancel}/></div>{creating.error && <div className="dvw-tree-create-error" style={{ paddingLeft: 42 + depth * 13 }}>{creating.error}</div>}</>}{rows.map(row => <div key={row.path}>
|
|
132
|
-
<div className="dvw-tree-row" data-dvw-upload-directory={row.kind === 'directory' ? row.path : undefined} data-selected={selected === row.path || selectedDirectory === row.path} data-drop-target={dropTarget && row.kind === 'directory'} style={{ paddingLeft: 6 + depth * 13 }} onClick={() => row.kind === 'directory' ? (onSelectDirectory(row.path), onToggle(row.path)) : onOpen(row.path)} onContextMenu={(event) => { event.preventDefault(); if (row.kind === 'directory') onSelectDirectory(row.path); setContext({ entry: row, x: event.clientX, y: event.clientY }) }} onDragEnter={event => { if (row.kind === 'directory' && event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.stopPropagation(); setDropTarget(true) } }} onDragOver={event => { if (row.kind === 'directory' && event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.stopPropagation(); setDropTarget(true) } }} onDragLeave={() => setDropTarget(false)} onDrop={event => row.kind === 'directory'
|
|
133
|
-
<span className="dvw-chevron">{row.kind === 'directory' && <Icon name={expanded.has(row.path) ? 'chevron-down' : 'chevron-right'}/>}</span><
|
|
196
|
+
return <>{error && <div className="dvw-error">{error}</div>}{creating?.directory === path && <><div className="dvw-tree-row dvw-tree-create-row" style={{ paddingLeft: 6 + depth * 13 }}><span className="dvw-chevron"/><Icon name={creating.kind === 'directory' ? 'folder' : 'new-file'} className={`dvw-file-icon ${creating.kind === 'directory' ? 'folder' : ''}`}/><input className="dvw-tree-create-input" value={creating.value} autoFocus aria-label={creating.kind === 'directory' ? '新建文件夹名称' : '新建文件名称'} aria-invalid={creating.error !== undefined} placeholder={creating.kind === 'directory' ? '文件夹名称' : '文件名称'} onChange={event => onCreatingChange(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); onCreatingSubmit() } if (event.key === 'Escape') { event.preventDefault(); onCreatingCancel() } }} onBlur={onCreatingCancel}/></div>{creating.error && <div className="dvw-tree-create-error" style={{ paddingLeft: 42 + depth * 13 }}>{creating.error}</div>}</>}{rows.map(row => <div className="dvw-tree-entry" key={row.path}>
|
|
197
|
+
<div className="dvw-tree-row" data-dvw-upload-directory={row.kind === 'directory' ? row.path : undefined} data-selected={selected === row.path || selectedDirectory === row.path} data-drop-target={dropTarget && row.kind === 'directory'} style={{ paddingLeft: 6 + depth * 13 }} onClick={() => row.kind === 'directory' ? (onSelectDirectory(row.path), onToggle(row.path)) : onOpen(row.path)} onContextMenu={(event) => { event.preventDefault(); if (row.kind === 'directory') onSelectDirectory(row.path); setContext({ entry: row, x: event.clientX, y: event.clientY }) }} draggable={renaming?.path !== row.path} onDragStart={event => startEntryDrag(event, sessionId, row)} onDragEnd={endEntryDrag} onDragEnter={event => { if (entryDragOver(event, entryDirectory(row), entryTarget(event, row), row.kind === 'directory' && !expanded.has(row.path) ? () => onToggle(row.path) : undefined)) return; if (row.kind === 'directory' && event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.stopPropagation(); setDropTarget(true) } }} onDragOver={event => { if (entryDragOver(event, entryDirectory(row), entryTarget(event, row))) return; if (row.kind === 'directory' && event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.stopPropagation(); setDropTarget(true) } }} onDragLeave={() => setDropTarget(false)} onDrop={event => { if (draggedEntry !== undefined) { void dropEntry(event, sessionId, entryDirectory(row), onChange); return } if (row.kind === 'directory') drop(event, row.path) }}>
|
|
198
|
+
<span className="dvw-chevron">{row.kind === 'directory' && <Icon name={expanded.has(row.path) ? 'chevron-down' : 'chevron-right'}/>}</span><FileIcon name={row.name} directory={row.kind === 'directory'} expanded={expanded.has(row.path)}/>{renaming?.path === row.path ? <input className="dvw-tree-rename" value={renaming.value} autoFocus onClick={event => event.stopPropagation()} onChange={event => setRenaming({ path: row.path, value: event.target.value })} onKeyDown={event => { if (event.key === 'Enter') void rename(row); if (event.key === 'Escape') setRenaming(undefined) }} onBlur={() => setRenaming(undefined)}/> : <span className="dvw-tree-name">{row.name}</span>}{row.kind === 'file' && statuses.has(row.path) && <span className="dvw-tree-status" data-status={statuses.get(row.path)}>{statuses.get(row.path)}</span>}
|
|
134
199
|
</div>
|
|
135
|
-
{row.kind === 'directory' && expanded.has(row.path) && <Tree {...{ sessionId, path: row.path, depth: depth + 1, expanded, selected, selectedDirectory, statuses, onToggle, onSelectDirectory, onOpen, onChange, onUpload, creating, onCreatingChange, onCreatingCancel, onCreatingSubmit }} />}
|
|
136
|
-
</div>)}{context && <div className="dvw-context-menu" role="menu" style={{ left: context.x, top: context.y }} onPointerDown={event => event.stopPropagation()}><button role="menuitem" onClick={() => { setRenaming({ path: context.entry.path, value: context.entry.name }); setContext(undefined) }}><Icon name="edit"/>重命名</button><button className="dvw-context-delete" role="menuitem" onClick={() => { setPendingDelete(context.entry); setContext(undefined) }}><Icon name="trash"/>删除</button></div>}{pendingDelete && <div className="dvw-confirm-backdrop" role="presentation"><section className="dvw-confirm" role="alertdialog" aria-modal="true" aria-labelledby="dvw-delete-title" aria-describedby="dvw-delete-message"><h2 id="dvw-delete-title">删除 {pendingDelete.kind === 'directory' ? '文件夹' : '文件'}</h2><p id="dvw-delete-message">是否删除“{pendingDelete.name}”?</p><p className="dvw-confirm-note">此操作无法撤销。</p><footer><button autoFocus onClick={() => setPendingDelete(undefined)}>取消</button><button className="dvw-danger-button" onClick={() => void remove()}>删除</button></footer></section></div>}</>
|
|
200
|
+
{row.kind === 'directory' && expanded.has(row.path) && <Tree {...{ sessionId, path: row.path, depth: depth + 1, expanded, selected, selectedDirectory, statuses, onToggle, onSelectDirectory, onOpen, onChange, onUpload, onAddToDsh, creating, onCreatingChange, onCreatingCancel, onCreatingSubmit }} />}
|
|
201
|
+
</div>)}{context && <div className="dvw-context-menu" role="menu" style={{ left: context.x, top: context.y }} onPointerDown={event => event.stopPropagation()}><button role="menuitem" onClick={() => { onAddToDsh(context.entry.path, context.entry.kind === 'directory'); setContext(undefined) }}><DshMark size={14}/>添加到 DSH 对话<kbd>{ADD_TO_DSH_SHORTCUT}</kbd></button><button role="menuitem" onClick={() => { setRenaming({ path: context.entry.path, value: context.entry.name }); setContext(undefined) }}><Icon name="edit"/>重命名</button><button className="dvw-context-delete" role="menuitem" onClick={() => { setPendingDelete(context.entry); setContext(undefined) }}><Icon name="trash"/>删除</button></div>}{pendingDelete && <div className="dvw-confirm-backdrop" role="presentation"><section className="dvw-confirm" role="alertdialog" aria-modal="true" aria-labelledby="dvw-delete-title" aria-describedby="dvw-delete-message"><h2 id="dvw-delete-title">删除 {pendingDelete.kind === 'directory' ? '文件夹' : '文件'}</h2><p id="dvw-delete-message">是否删除“{pendingDelete.name}”?</p><p className="dvw-confirm-note">此操作无法撤销。</p><footer><button autoFocus onClick={() => setPendingDelete(undefined)}>取消</button><button className="dvw-danger-button" onClick={() => void remove()}>删除</button></footer></section></div>}</>
|
|
137
202
|
}
|
|
138
203
|
|
|
139
|
-
function Explorer(props: { sessionId: string; cwd?: string; selected?: string; onOpen(path: string): void }) {
|
|
204
|
+
function Explorer(props: { sessionId: string; cwd?: string; selected?: string; onOpen(path: string): void; onAddToDsh(path: string, directory: boolean): void }) {
|
|
205
|
+
// The row the user last clicked, which the Explorer shortcut adds to the DSH chat.
|
|
206
|
+
const lastEntry = useRef<{ path: string; directory: boolean }>()
|
|
140
207
|
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [revision, setRevision] = useState(0); const [statuses, setStatuses] = useState<Map<string, string>>(new Map()); const [dropTarget, setDropTarget] = useState(false); const [selectedDirectory, setSelectedDirectory] = useState(''); const [creating, setCreating] = useState<CreatingEntry>(); const picker = useRef<HTMLInputElement>(null)
|
|
141
208
|
useEffect(() => { if (props.cwd) { try { setExpanded(new Set(JSON.parse(localStorage.getItem(`dvw:tree:${props.cwd}`) ?? '[]') as string[])) } catch { setExpanded(new Set()) } } }, [props.cwd])
|
|
142
209
|
useEffect(() => { if (props.cwd) localStorage.setItem(`dvw:tree:${props.cwd}`, JSON.stringify([...expanded])) }, [props.cwd, expanded])
|
|
@@ -179,8 +246,8 @@ function Explorer(props: { sessionId: string; cwd?: string; selected?: string; o
|
|
|
179
246
|
}, [upload])
|
|
180
247
|
const drop = (event: DragEvent) => { event.preventDefault(); setDropTarget(false); if (event.dataTransfer.files.length > 0) void upload('', event.dataTransfer.files) }
|
|
181
248
|
const destination = uploadDestination(selectedDirectory)
|
|
182
|
-
const treeDrag = (event: DragEvent) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.stopPropagation(); setDropTarget(true) } }
|
|
183
|
-
return <><div className="dvw-side-head">资源管理器<div className="dvw-toolbar"><input ref={picker} className="dvw-file-picker" type="file" accept="*/*" multiple onChange={event => { if (event.currentTarget.files) void upload(destination, event.currentTarget.files); event.currentTarget.value = '' }}/><button className="dvw-icon" title={`上传文件到 ${destination || '工作区根目录'}`} onClick={() => picker.current?.click()}><Icon name="cloud-upload"/></button><button className="dvw-icon" title="新建文件" onClick={() => startCreating('file')}><Icon name="new-file"/></button><button className="dvw-icon" title="新建文件夹" onClick={() => startCreating('directory')}><Icon name="new-folder"/></button><button className="dvw-icon" title="刷新" onClick={() => { setRevision(x => x + 1); window.dispatchEvent(new Event('dvw-files-changed')) }}><Icon name="refresh"/></button></div></div><div className="dvw-tree" data-dvw-upload-directory="" data-drop-target={dropTarget} key={revision} onDragEnter={treeDrag} onDragOver={treeDrag} onDragLeave={
|
|
249
|
+
const treeDrag = (event: DragEvent) => { if (entryDragOver(event, '', event.currentTarget as HTMLElement)) return; if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.stopPropagation(); setDropTarget(true) } }
|
|
250
|
+
return <><div className="dvw-side-head">资源管理器<div className="dvw-toolbar"><input ref={picker} className="dvw-file-picker" type="file" accept="*/*" multiple onChange={event => { if (event.currentTarget.files) void upload(destination, event.currentTarget.files); event.currentTarget.value = '' }}/><button className="dvw-icon" title={`上传文件到 ${destination || '工作区根目录'}`} onClick={() => picker.current?.click()}><Icon name="cloud-upload"/></button><button className="dvw-icon" title="新建文件" onClick={() => startCreating('file')}><Icon name="new-file"/></button><button className="dvw-icon" title="新建文件夹" onClick={() => startCreating('directory')}><Icon name="new-folder"/></button><button className="dvw-icon" title="刷新" onClick={() => { setRevision(x => x + 1); window.dispatchEvent(new Event('dvw-files-changed')) }}><Icon name="refresh"/></button></div></div><div className="dvw-tree" tabIndex={-1} data-dvw-upload-directory="" data-drop-target={dropTarget} key={revision} onKeyDown={event => { if (!isAddToDshShortcut(event.nativeEvent) || lastEntry.current === undefined) return; event.preventDefault(); props.onAddToDsh(lastEntry.current.path, lastEntry.current.directory) }} onDragEnter={treeDrag} onDragOver={treeDrag} onDragLeave={event => { setDropTarget(false); if (!(event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) clearMoveTarget() }} onDrop={event => { if (draggedEntry !== undefined) { void dropEntry(event, props.sessionId, '', () => setRevision(x => x + 1)); return } drop(event) }}><Tree sessionId={props.sessionId} path="" depth={0} expanded={expanded} selected={props.selected} selectedDirectory={selectedDirectory} statuses={statuses} onToggle={path => setExpanded(current => { const next = new Set(current); next.has(path) ? next.delete(path) : next.add(path); return next })} onSelectDirectory={path => { lastEntry.current = { path, directory: true }; setSelectedDirectory(path) }} onOpen={path => { lastEntry.current = { path, directory: false }; props.onOpen(path) }} onAddToDsh={props.onAddToDsh} onChange={() => setRevision(x => x + 1)} onUpload={(directory, files) => void upload(directory, files)} creating={creating} onCreatingChange={value => setCreating(current => current && ({ ...current, value, error: undefined }))} onCreatingCancel={() => setCreating(undefined)} onCreatingSubmit={() => void submitCreating()} /></div></>
|
|
184
251
|
}
|
|
185
252
|
|
|
186
253
|
/** Return the workspace-relative directory used by the file picker. */
|
|
@@ -189,6 +256,16 @@ export function uploadDestination(selectedDirectory?: string): string { return s
|
|
|
189
256
|
/** Join an inline-created entry name to the currently selected Explorer directory. */
|
|
190
257
|
export function createdEntryPath(directory: string, name: string): string { return directory ? `${directory}/${name}` : name }
|
|
191
258
|
|
|
259
|
+
const BINARY_DIFF_FILE = /\.(?:pdf|docx?|odt|xlsx?|ods|pptx?|odp|zip|7z|rar|gz|tgz|bz2|xz|tar|exe|dll|so|dylib|bin|class|jar|war|wasm|o|obj|lib|a|pdb|woff2?|ttf|otf|eot|mp3|m4a|mp4|wav|flac|ogg|mkv|mov|avi|webm|png|jpe?g|gif|bmp|webp|avif|ico|tiff?|pyc|db|sqlite3?|icns)$/i
|
|
260
|
+
|
|
261
|
+
/** Git prints many PDFs and archives as a text patch, so classify binary diffs by extension instead. */
|
|
262
|
+
export function isBinaryDiffPath(path: string): boolean { return BINARY_DIFF_FILE.test(path) }
|
|
263
|
+
|
|
264
|
+
const IMAGE_PREVIEW_FILE = /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i
|
|
265
|
+
|
|
266
|
+
/** Files the workbench can show as a document or image preview, which Git changes should open directly. */
|
|
267
|
+
export function isPreviewDocumentPath(path: string): boolean { return documentPreviewKind(path) !== undefined || IMAGE_PREVIEW_FILE.test(path) }
|
|
268
|
+
|
|
192
269
|
/** Replace the final path component while preserving its workspace-relative directory. */
|
|
193
270
|
export function renamedPath(path: string, name: string): string { return [...path.split('/').slice(0, -1), name].filter(Boolean).join('/') }
|
|
194
271
|
|
|
@@ -202,7 +279,7 @@ function MarkdownOutline({headings,onClose,onHeading}:{headings:MarkdownHeading[
|
|
|
202
279
|
return <aside className="dvw-outline dvw-editor-outline"><div className="dvw-outline-head"><span><Icon name="list-tree"/> 大纲</span><button className="dvw-icon" title="关闭大纲" onClick={onClose}><Icon name="close"/></button></div><div className="dvw-outline-list">{headings.map(heading=><button className="dvw-outline-item" key={heading.index} style={{paddingLeft:8+(heading.depth-1)*13}} onClick={()=>onHeading(heading.index)}>{heading.text}</button>)}</div></aside>
|
|
203
280
|
}
|
|
204
281
|
|
|
205
|
-
function Search(props: { sessionId: string; onOpen(path: string, line: number, column: number): void }) {
|
|
282
|
+
function Search(props: { sessionId: string; onOpen(path: string, line: number, column: number, match: string): void }) {
|
|
206
283
|
const [query, setQuery] = useState(''); const [include, setInclude] = useState(''); const [exclude, setExclude] = useState(''); const [flags, setFlags] = useState({ caseSensitive: false, wholeWord: false, regex: false }); const [matches, setMatches] = useState<SearchMatch[]>([]); const [limited, setLimited] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState<string>()
|
|
207
284
|
const pending = useRef<AbortController>()
|
|
208
285
|
useEffect(() => () => pending.current?.abort(), [])
|
|
@@ -212,7 +289,7 @@ function Search(props: { sessionId: string; onOpen(path: string, line: number, c
|
|
|
212
289
|
for (const match of matches) grouped.set(match.path, [...(grouped.get(match.path) ?? []), match])
|
|
213
290
|
return grouped
|
|
214
291
|
}, [matches])
|
|
215
|
-
return <><div className="dvw-side-head">搜索</div><form className="dvw-search-form" onSubmit={event => { event.preventDefault(); void search() }}><input className="dvw-input" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" autoFocus/><div className="dvw-options">{([['caseSensitive','Aa'],['wholeWord','Ab'],['regex','.*']] as const).map(([key,label]) => <label key={key}><input type="checkbox" checked={flags[key]} onChange={e => setFlags({ ...flags, [key]: e.target.checked })}/>{label}</label>)}<button className="dvw-icon" title="搜索" disabled={busy}><Icon name="search"/></button></div><input className="dvw-input" value={include} onChange={e=>setInclude(e.target.value)} placeholder="包含文件,例如 src/**"/><input className="dvw-input" value={exclude} onChange={e=>setExclude(e.target.value)} placeholder="排除文件,例如 **/*.test.ts"/></form>{error&&<div className="dvw-error">{error}</div>}{limited&&<div className="dvw-message">结果已截断到 2000 条</div>}<div className="dvw-results">{[...groups].map(([path, rows]) => <div key={path}><div className="dvw-result-file"><Icon name="chevron-down"/> {path} <span className="dvw-badge">{rows.length}</span></div>{rows.map((row,index)=><div className="dvw-result" key={`${row.line}:${row.column}:${index}`} onClick={()=>props.onOpen(path,row.line,row.column)}><span className="dvw-line">{row.line}</span><span className="dvw-preview">{row.preview}</span></div>)}</div>)}</div></>
|
|
292
|
+
return <><div className="dvw-side-head">搜索</div><form className="dvw-search-form" onSubmit={event => { event.preventDefault(); void search() }}><input className="dvw-input" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" autoFocus/><div className="dvw-options">{([['caseSensitive','Aa'],['wholeWord','Ab'],['regex','.*']] as const).map(([key,label]) => <label key={key}><input type="checkbox" checked={flags[key]} onChange={e => setFlags({ ...flags, [key]: e.target.checked })}/>{label}</label>)}<button className="dvw-icon" title="搜索" disabled={busy}><Icon name="search"/></button></div><input className="dvw-input" value={include} onChange={e=>setInclude(e.target.value)} placeholder="包含文件,例如 src/**"/><input className="dvw-input" value={exclude} onChange={e=>setExclude(e.target.value)} placeholder="排除文件,例如 **/*.test.ts"/></form>{error&&<div className="dvw-error">{error}</div>}{limited&&<div className="dvw-message">结果已截断到 2000 条</div>}<div className="dvw-results">{[...groups].map(([path, rows]) => <div key={path}><div className="dvw-result-file"><Icon name="chevron-down"/> {path} <span className="dvw-badge">{rows.length}</span></div>{rows.map((row,index)=><div className="dvw-result" key={`${row.line}:${row.column}:${index}`} onClick={()=>props.onOpen(path,row.line,row.column,row.match||query)}><span className="dvw-line">{row.line}</span><span className="dvw-preview">{row.preview}</span></div>)}</div>)}</div></>
|
|
216
293
|
}
|
|
217
294
|
|
|
218
295
|
export interface GraphTrack { hash: string; color: number }
|
|
@@ -239,31 +316,51 @@ export function graphRows(commits: GitCommit[]): GraphRow[] {
|
|
|
239
316
|
})
|
|
240
317
|
}
|
|
241
318
|
|
|
242
|
-
|
|
319
|
+
// VS Code SCM graph geometry: 11px swimlanes, lane-width arcs into and out of the node, 5px lane-shift curves.
|
|
320
|
+
const GRAPH_LANE = 11
|
|
321
|
+
const GRAPH_CURVE = 5
|
|
322
|
+
const GRAPH_HEIGHT = 38
|
|
323
|
+
const GRAPH_MID = GRAPH_HEIGHT / 2
|
|
324
|
+
const GRAPH_COLORS = ['var(--dvw-graph-ref)','#ffb000','#dc267f','#994f00','#40b0a6','#b66dff']
|
|
243
325
|
function graphColor(color:number):string{return GRAPH_COLORS[color%GRAPH_COLORS.length]!}
|
|
244
|
-
function
|
|
326
|
+
function graphX(lane:number):number{return GRAPH_LANE*(lane+1)}
|
|
327
|
+
function graphWidth(row:GraphRow):number{return GRAPH_LANE*(Math.max(row.before.length,row.after.length,row.lane+1,1)+1)}
|
|
245
328
|
|
|
246
|
-
/**
|
|
329
|
+
/** Move a passing lane to its new column with two 5px arcs around the row midline. */
|
|
247
330
|
export function graphTrackPath(from: number, to: number, startY: number, endY: number, bendY: number): string {
|
|
248
331
|
if (from === to) return `M ${from} ${startY} V ${endY}`
|
|
249
332
|
const direction = Math.sign(to - from)
|
|
250
|
-
const radius = Math.min(
|
|
251
|
-
|
|
333
|
+
const radius = Math.min(GRAPH_CURVE, Math.abs(to - from) / 2, Math.abs(bendY - startY), Math.abs(endY - bendY))
|
|
334
|
+
const sweep = direction < 0 ? 1 : 0
|
|
335
|
+
return `M ${from} ${startY} V ${bendY - radius} A ${radius} ${radius} 0 0 ${sweep} ${from + direction * radius} ${bendY} H ${to - direction * radius} A ${radius} ${radius} 0 0 ${1 - sweep} ${to} ${bendY + radius} V ${endY}`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Leave the node horizontally and turn down into a parent lane with a lane-width arc. */
|
|
339
|
+
export function graphParentPath(from: number, to: number, midY: number, endY: number): string {
|
|
340
|
+
if (from === to) return `M ${from} ${midY} V ${endY}`
|
|
341
|
+
const direction = Math.sign(to - from)
|
|
342
|
+
return `M ${from} ${midY} H ${to - direction * GRAPH_LANE} A ${GRAPH_LANE} ${GRAPH_LANE} 0 0 ${direction > 0 ? 1 : 0} ${to} ${midY + GRAPH_LANE} V ${endY}`
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function GraphNode({row,x,color}:{row:GraphRow;x:number;color:string}) {
|
|
346
|
+
if(row.commit.refs.some(ref=>ref==='HEAD'||ref.startsWith('HEAD ->')))return <><circle className="dvw-graph-node" cx={x} cy={GRAPH_MID} r="7" style={{fill:color}}/><circle className="dvw-graph-node" cx={x} cy={GRAPH_MID} r="2" strokeWidth="4"/></>
|
|
347
|
+
if(row.commit.parents.length>1)return <><circle className="dvw-graph-node" cx={x} cy={GRAPH_MID} r="6" style={{fill:color}}/><circle className="dvw-graph-node" cx={x} cy={GRAPH_MID} r="3" style={{fill:color}}/></>
|
|
348
|
+
return <circle className="dvw-graph-node" cx={x} cy={GRAPH_MID} r="5" style={{fill:color}}/>
|
|
252
349
|
}
|
|
253
350
|
|
|
254
351
|
function GraphTracks({row}:{row:GraphRow}) {
|
|
255
|
-
const width=graphWidth(row);const x=row.lane
|
|
256
|
-
return <svg className="dvw-graph-svg" width={width} height=
|
|
257
|
-
{row.before.map((track,lane)=>{if(track.hash===row.commit.hash)return null;const target=row.after.findIndex(next=>next.hash===track.hash);const nextLane=target>=0?target:lane;return <path key={`continue-${track.hash}`} d={graphTrackPath(lane
|
|
258
|
-
<path d={`M ${x} 0 V
|
|
259
|
-
{row.parentLanes.map((parent,index)=>{const
|
|
260
|
-
<
|
|
352
|
+
const width=graphWidth(row);const x=graphX(row.lane);const current=row.before[row.lane]!
|
|
353
|
+
return <svg className="dvw-graph-svg" width={width} height={GRAPH_HEIGHT} viewBox={`0 0 ${width} ${GRAPH_HEIGHT}`} aria-hidden="true">
|
|
354
|
+
{row.before.map((track,lane)=>{if(track.hash===row.commit.hash)return null;const target=row.after.findIndex(next=>next.hash===track.hash);const nextLane=target>=0?target:lane;return <path className="dvw-graph-line" key={`continue-${track.hash}`} d={graphTrackPath(graphX(lane),graphX(nextLane),0,GRAPH_HEIGHT,GRAPH_MID)} style={{stroke:graphColor(track.color)}}/>})}
|
|
355
|
+
<path className="dvw-graph-line" d={`M ${x} 0 V ${GRAPH_MID}`} style={{stroke:graphColor(current.color)}}/>
|
|
356
|
+
{row.parentLanes.map((parent,index)=>{const track=row.after[parent]!;return <path className="dvw-graph-line" key={`parent-${track.hash}-${index}`} d={graphParentPath(x,graphX(parent),GRAPH_MID,GRAPH_HEIGHT)} style={{stroke:graphColor(track.color)}}/>})}
|
|
357
|
+
<GraphNode row={row} x={x} color={graphColor(current.color)}/>
|
|
261
358
|
</svg>
|
|
262
359
|
}
|
|
263
360
|
|
|
264
361
|
function GraphContinuation({row}:{row:GraphRow}) {
|
|
265
362
|
const width=graphWidth(row)
|
|
266
|
-
return <svg className="dvw-graph-continuation" width={width} height="100%" viewBox={`0 0 ${width} 100`} preserveAspectRatio="none" aria-hidden="true">{row.after.map((track,lane)=><line key={track.hash} x1={lane
|
|
363
|
+
return <svg className="dvw-graph-continuation" width={width} height="100%" viewBox={`0 0 ${width} 100`} preserveAspectRatio="none" aria-hidden="true">{row.after.map((track,lane)=><line className="dvw-graph-line" key={track.hash} x1={graphX(lane)} y1="0" x2={graphX(lane)} y2="100" style={{stroke:graphColor(track.color)}}/>)}</svg>
|
|
267
364
|
}
|
|
268
365
|
|
|
269
366
|
function CommitGraph({ commits, expanded, files, onToggle, onOpenFile }: { commits: GitCommit[]; expanded?: string; files: Map<string,GitCommitFile[]>; onToggle(commit:GitCommit):void; onOpenFile(commit:GitCommit,file:GitCommitFile):void }) {
|
|
@@ -271,7 +368,7 @@ function CommitGraph({ commits, expanded, files, onToggle, onOpenFile }: { commi
|
|
|
271
368
|
const opened=expanded===row.commit.hash
|
|
272
369
|
return <div className="dvw-graph-entry" data-expanded={opened} key={row.commit.hash}>
|
|
273
370
|
<button className="dvw-graph-row" aria-expanded={opened} onClick={()=>onToggle(row.commit)} title={`${row.commit.hash}\n${row.commit.author}`}><GraphTracks row={row}/><span className="dvw-graph-copy"><span className="dvw-graph-subject">{row.commit.subject}</span><span className="dvw-graph-meta">{row.commit.author} · {new Date(row.commit.date).toLocaleDateString()}</span></span>{row.commit.refs.slice(0,2).map(ref=><span className="dvw-ref" key={ref}>{ref}</span>)}<Icon name={opened?'chevron-down':'chevron-right'}/></button>
|
|
274
|
-
{opened&&<div className="dvw-commit-files" style={{paddingLeft:graphWidth(row)+7}}><GraphContinuation row={row}/><div className="dvw-commit-file-list">{(files.get(row.commit.hash)??[]).map(file=><button className="dvw-commit-file" key={`${file.status}:${file.path}`} onClick={()=>onOpenFile(row.commit,file)}><
|
|
371
|
+
{opened&&<div className="dvw-commit-files" style={{paddingLeft:graphWidth(row)+7}}><GraphContinuation row={row}/><div className="dvw-commit-file-list">{(files.get(row.commit.hash)??[]).map(file=><button className="dvw-commit-file" key={`${file.status}:${file.path}`} onClick={()=>onOpenFile(row.commit,file)}><FileIcon name={file.path.split('/').at(-1) ?? file.path} className="dvw-file-icon"/><span className="dvw-commit-file-name">{file.path.split('/').at(-1)}</span><span className="dvw-commit-file-dir">{file.path.split('/').slice(0,-1).join('/')}</span><span className="dvw-badge">{file.status}</span></button>)}</div></div>}
|
|
275
372
|
</div>
|
|
276
373
|
})}</div>
|
|
277
374
|
}
|
|
@@ -281,60 +378,82 @@ function GitGroupHeader({ title, count, expanded, onToggle, children }: { title:
|
|
|
281
378
|
}
|
|
282
379
|
|
|
283
380
|
function Git(props: { sessionId: string; onDiff(key:string, diff:GitDiff, title?:string, sourcePath?:string): void }) {
|
|
284
|
-
const [status, setStatus] = useState<GitStatus>(); const [commits,setCommits]=useState<GitCommit[]>([]); const [message, setMessage] = useState(''); const [error, setError] = useState<string>(); const [busy,setBusy]=useState(false); const [expanded,setExpanded]=useState({staged:true,changes:true,graph:true});const [expandedCommit,setExpandedCommit]=useState<string>();const [commitFileMap,setCommitFileMap]=useState<Map<string,GitCommitFile[]>>(new Map())
|
|
381
|
+
const [status, setStatus] = useState<GitStatus>(); const [commits,setCommits]=useState<GitCommit[]>([]); const [message, setMessage] = useState(''); const [error, setError] = useState<string>(); const [busy,setBusy]=useState(false); const [expanded,setExpanded]=useState({staged:true,changes:true,graph:true});const [expandedCommit,setExpandedCommit]=useState<string>();const [commitFileMap,setCommitFileMap]=useState<Map<string,GitCommitFile[]>>(new Map());const [branchMenu,setBranchMenu]=useState(false);const [branches,setBranches]=useState<GitBranch[]>([]);const [newBranch,setNewBranch]=useState('');const [branchError,setBranchError]=useState<string>()
|
|
285
382
|
const refresh = useCallback(() => { void Promise.all([api<GitStatus>('git.status',{sessionId:props.sessionId}),api<GitCommit[]>('git.action',{sessionId:props.sessionId,action:'log'})]).then(([nextStatus,nextCommits])=>{setStatus(nextStatus);setCommits(nextCommits);setError(undefined)}).catch(error=>{setStatus(undefined);setCommits([]);setError(error instanceof ApiError&&error.code==='not-repository'?undefined:String(error))}) }, [props.sessionId])
|
|
286
383
|
const refreshDebounced = useDebouncedCallback(refresh, 500)
|
|
287
384
|
useEffect(refresh,[refresh]); useEffect(()=>{const fn=()=>refreshDebounced();window.addEventListener('dvw-files-changed',fn);return()=>window.removeEventListener('dvw-files-changed',fn)},[refreshDebounced])
|
|
288
385
|
const action = async (name: string, path?: string, value?: string) => { setBusy(true);try { const result = await api<GitStatus | GitDiff>('git.action',{sessionId:props.sessionId,action:name,path,value}); if(name==='diff'&&'diff'in result){const key=path??'';props.onDiff(key,result,key.split('/').at(-1)??key,key)} else {setStatus(result as GitStatus);if(name==='commit')refresh()} } catch(error){setError(String(error))}finally{setBusy(false)} }
|
|
289
|
-
|
|
386
|
+
useEffect(()=>{if(!branchMenu)return;const close=()=>setBranchMenu(false);const escape=(event:KeyboardEvent)=>{if(event.key==='Escape')close()};window.addEventListener('pointerdown',close);window.addEventListener('keydown',escape);return()=>{window.removeEventListener('pointerdown',close);window.removeEventListener('keydown',escape)}},[branchMenu])
|
|
387
|
+
const openBranchMenu=async()=>{if(branchMenu){setBranchMenu(false);return}setBusy(true);try{setBranches(await api<GitBranch[]>('git.action',{sessionId:props.sessionId,action:'branch.list'}));setBranchMenu(true)}catch(error){setError(String(error))}finally{setBusy(false)}}
|
|
388
|
+
const switchBranch=async(name:string)=>{if(name===status?.branch){setBranchMenu(false);return}setBusy(true);try{setStatus(await api<GitStatus>('git.action',{sessionId:props.sessionId,action:'branch.switch',value:name}));setBranchMenu(false);refresh()}catch(error){setError(undefined);setBranchError(branchErrorMessage(error))}finally{setBusy(false)}}
|
|
389
|
+
const createBranch=async()=>{const name=newBranch.trim();if(name==='')return;setBusy(true);try{setStatus(await api<GitStatus>('git.action',{sessionId:props.sessionId,action:'branch.create',value:name}));setNewBranch('');setBranchMenu(false);refresh()}catch(error){setError(undefined);setBranchError(branchErrorMessage(error))}finally{setBusy(false)}}
|
|
290
390
|
const toggleCommit=async(commit:GitCommit)=>{if(expandedCommit===commit.hash){setExpandedCommit(undefined);return}setExpandedCommit(commit.hash);if(commitFileMap.has(commit.hash))return;setBusy(true);try{const files=await api<GitCommitFile[]>('git.action',{sessionId:props.sessionId,action:'commit.files',value:commit.hash});setCommitFileMap(current=>new Map(current).set(commit.hash,files))}catch(error){setError(String(error))}finally{setBusy(false)}}
|
|
291
391
|
const openCommitFile=async(commit:GitCommit,file:GitCommitFile)=>{setBusy(true);try{const result=await api<GitDiff>('git.action',{sessionId:props.sessionId,action:'commit.file.diff',path:file.path,value:commit.hash});props.onDiff(`commit:${commit.hash}:${file.path}`,result,file.path.split('/').at(-1)??file.path,file.path)}catch(error){setError(String(error))}finally{setBusy(false)}}
|
|
292
392
|
const staged=status?.changes.filter(change=>change.index!=='.'&&change.index!=='?')??[]
|
|
293
393
|
const changed=status?.changes.filter(change=>change.worktree!=='.'||change.untracked)??[]
|
|
294
|
-
const renderChange=(change:GitStatus['changes'][number],group:'staged'|'worktree')=><div className="dvw-change" key={`${group}:${change.path}`} onClick={()=>void action('diff',change.path,change.untracked?'untracked':group)}><
|
|
295
|
-
return <><div className="dvw-side-head">源代码管理<div className="dvw-toolbar"><button className="dvw-icon" title="刷新" disabled={busy} onClick={refresh}><Icon name="refresh"/></button></div></div>{error&&<div className="dvw-error">{error}</div>}{status&&<><div className="dvw-git-head"><button className="dvw-branch" onClick={()=>void
|
|
394
|
+
const renderChange=(change:GitStatus['changes'][number],group:'staged'|'worktree')=><div className="dvw-change" key={`${group}:${change.path}`} onClick={()=>void action('diff',change.path,change.untracked?'untracked':group)}><FileIcon name={change.path.split('/').at(-1) ?? change.path}/><span className="dvw-change-path">{change.path}</span><span className="dvw-badge">{change.untracked?'U':group==='staged'?change.index:change.worktree}</span><button className="dvw-icon" title={group==='staged'?'取消暂存':'暂存更改'} onClick={event=>{event.stopPropagation();void action(group==='staged'?'unstage':'stage',change.path)}}><Icon name={group==='staged'?'remove':'add'}/></button>{group==='worktree'&&<button className="dvw-icon" title="放弃更改" onClick={event=>{event.stopPropagation();if(confirm(`放弃 ${change.path} 的更改?此操作无法撤销。`))void action('discard',change.path,change.untracked?'untracked':undefined)}}><Icon name="discard"/></button>}</div>
|
|
395
|
+
return <><div className="dvw-side-head">源代码管理<div className="dvw-toolbar"><button className="dvw-icon" title="刷新" disabled={busy} onClick={refresh}><Icon name="refresh"/></button></div></div>{error&&<div className="dvw-error">{error}</div>}{status&&<><div className="dvw-git-head"><button className="dvw-branch" aria-haspopup="menu" aria-expanded={branchMenu} onClick={()=>void openBranchMenu()}><Icon name="git-branch"/> {status.branch} <Icon name="chevron-down" className="dvw-branch-caret"/></button><span>↑{status.ahead} ↓{status.behind}</span>{branchMenu&&<div className="dvw-branch-menu" role="menu" onPointerDown={event=>event.stopPropagation()}><div className="dvw-branch-current">当前分支:{status.branch}</div><div className="dvw-branch-list">{branches.map(branch=><button key={branch.name} role="menuitem" data-active={branch.name===status.branch} disabled={busy||branch.worktree!==undefined} title={branch.worktree!==undefined?`已在 worktree ${branch.worktree} 中检出`:undefined} onClick={()=>void switchBranch(branch.name)}><Icon name={branch.name===status.branch?'check':'git-branch'}/><span>{branch.name}</span>{branch.worktree!==undefined&&<span className="dvw-branch-worktree">worktree</span>}</button>)}</div><div className="dvw-branch-create"><input className="dvw-input" value={newBranch} placeholder="新分支名称" onChange={event=>setNewBranch(event.target.value)} onKeyDown={event=>{if(event.key==='Enter'){event.preventDefault();void createBranch()}}}/><button className="dvw-icon dvw-branch-create-button" title="创建并切换分支" disabled={busy||!newBranch.trim()} onClick={()=>void createBranch()}><Icon name="add"/></button></div></div>}</div><div className="dvw-commit"><input className="dvw-input" value={message} onChange={event=>setMessage(event.target.value)} onKeyDown={event=>{if(event.ctrlKey&&event.key==='Enter'&&message.trim())void action('commit',undefined,message).then(()=>setMessage(''))}} placeholder={`消息 (Ctrl+Enter 在“${status.branch}”提交)`}/><button className="dvw-commit-button" disabled={busy||!message.trim()} onClick={()=>void action('commit',undefined,message).then(()=>setMessage(''))}><Icon name="check"/> 提交</button></div><div className="dvw-git">{staged.length>0&&<section><GitGroupHeader title="暂存的更改" count={staged.length} expanded={expanded.staged} onToggle={()=>setExpanded(value=>({...value,staged:!value.staged}))}><button className="dvw-icon" title="取消暂存所有更改" onClick={()=>void action('unstage.all')}><Icon name="remove"/></button></GitGroupHeader>{expanded.staged&&staged.map(change=>renderChange(change,'staged'))}</section>}<section><GitGroupHeader title="更改" count={changed.length} expanded={expanded.changes} onToggle={()=>setExpanded(value=>({...value,changes:!value.changes}))}><button className="dvw-icon" title="放弃所有更改" onClick={()=>{if(confirm('放弃所有已跟踪和未跟踪的更改?此操作无法撤销。'))void action('discard.all')}}><Icon name="discard"/></button><button className="dvw-icon" title="暂存所有更改" onClick={()=>void action('stage.all')}><Icon name="add"/></button></GitGroupHeader>{expanded.changes&&changed.map(change=>renderChange(change,'worktree'))}</section><section className="dvw-graph"><GitGroupHeader title="图表" count={commits.length} expanded={expanded.graph} onToggle={()=>setExpanded(value=>({...value,graph:!value.graph}))}><button className="dvw-icon" title="刷新提交图表" onClick={refresh}><Icon name="refresh"/></button></GitGroupHeader>{expanded.graph&&<CommitGraph commits={commits} expanded={expandedCommit} files={commitFileMap} onToggle={commit=>void toggleCommit(commit)} onOpenFile={(commit,file)=>void openCommitFile(commit,file)}/>}</section></div>{branchError!==undefined&&<div className="dvw-confirm-backdrop" role="presentation" onMouseDown={()=>setBranchError(undefined)}><section className="dvw-confirm" role="alertdialog" aria-modal="true" aria-labelledby="dvw-branch-error-title" onMouseDown={event=>event.stopPropagation()}><h2 id="dvw-branch-error-title">无法切换分支</h2><p className="dvw-branch-error-message">{branchError}</p><footer><button autoFocus onClick={()=>setBranchError(undefined)}>确定</button></footer></section></div>}</>}</>
|
|
296
396
|
}
|
|
297
397
|
|
|
298
|
-
|
|
398
|
+
/** Keep range dragging responsive and commit the editor font size after a short pause. */
|
|
399
|
+
function FontSizeControl({ value, onChange }: { value: number; onChange(value: number): void }) {
|
|
400
|
+
const [draft, setDraft] = useState(value)
|
|
401
|
+
useEffect(() => setDraft(value), [value])
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (draft === value) return
|
|
404
|
+
const timer = setTimeout(() => onChange(draft), 180)
|
|
405
|
+
return () => clearTimeout(timer)
|
|
406
|
+
}, [draft, value, onChange])
|
|
407
|
+
return <label className="dvw-setting-range"><span>编辑器字体大小</span><input type="range" min="11" max="20" step="1" value={draft} onChange={event=>setDraft(Number(event.target.value))}/><output>{draft}px</output></label>
|
|
408
|
+
}
|
|
299
409
|
|
|
300
|
-
|
|
410
|
+
const ExplorerView = memo(Explorer)
|
|
411
|
+
const SearchView = memo(Search)
|
|
412
|
+
const GitView = memo(Git)
|
|
413
|
+
|
|
414
|
+
export function Workbench({ service, sessionId, cwd, navigation }: { service: VscodeWorkbench; sessionId?: string; cwd?: string; navigation?: DshNavigation }) {
|
|
301
415
|
const [snapshot, setSnapshot] = useState(() => service.getSnapshot()); const [panels, setPanels] = useState(() => service.getPanels())
|
|
302
416
|
useEffect(() => service.subscribe(() => { setSnapshot(service.getSnapshot()); setPanels(service.getPanels()) }), [service])
|
|
303
|
-
const [workspace,setWorkspace]=useState<{cwd:string;name:string}>(); const [tabs,setTabs]=useState<Tab[]>([]); const [active,setActive]=useState<string>(); const [error,setError]=useState<string>(); const [panelHeight,setPanelHeight]=useState(260); const [sideWidth,setSideWidth]=useState(210); const [primarySideVisible,setPrimarySideVisible]=useState(()=>localStorage.getItem('dvw:primary-side-visible')!=='closed');const [statusVisible,setStatusVisible]=useState(()=>localStorage.getItem('dvw:status-bar-visible')==='open');const [settingsOpen,setSettingsOpen]=useState(false);const [chatWidth,setChatWidth]=useState(()=>Math.max(400,Math.min(720,Math.round(Number(localStorage.getItem('dvw:dsh-chat-width'))||420))));const [chatVisible,setChatVisible]=useState(()=>localStorage.getItem('dvw:dsh-chat-visible')!=='closed');const [outlineVisible,setOutlineVisible]=useState(()=>localStorage.getItem('dvw:outline')!=='closed');const [
|
|
417
|
+
const [workspace,setWorkspace]=useState<{cwd:string;name:string}>(); const [tabs,setTabs]=useState<Tab[]>([]); const [active,setActive]=useState<string>(); const [error,setError]=useState<string>(); const [panelHeight,setPanelHeight]=useState(260); const [sideWidth,setSideWidth]=useState(210); const [primarySideVisible,setPrimarySideVisible]=useState(()=>localStorage.getItem('dvw:primary-side-visible')!=='closed');const [statusVisible,setStatusVisible]=useState(()=>localStorage.getItem('dvw:status-bar-visible')==='open');const [settingsOpen,setSettingsOpen]=useState(false);const [chatWidth,setChatWidth]=useState(()=>Math.max(400,Math.min(720,Math.round(Number(localStorage.getItem('dvw:dsh-chat-width'))||420))));const [chatVisible,setChatVisible]=useState(()=>localStorage.getItem('dvw:dsh-chat-visible')!=='closed');const [outlineVisible,setOutlineVisible]=useState(()=>localStorage.getItem('dvw:outline')!=='closed');const [tsIntellisense,setTsIntellisense]=useState(tsIntellisenseEnabled);const [tsIndex,setTsIndex]=useState<{state:'idle'|'loading'|'ready'|'error';files?:number;truncated?:boolean;error?:string}>({state:'idle'});const [lspServers,setLspServers]=useState<LspServerStatus[]>([]);const [lspPrompt,setLspPrompt]=useState<{id:string;name:string;builtin:boolean;installable:boolean;hint?:string}>();const dismissedLsp=useRef(new Set<string>());const [lspBusy,setLspBusy]=useState<string>();const [lspError,setLspError]=useState('');const lspManager=useRef<LspManager>();const [fontSize,setFontSize]=useState(()=>{const saved=Math.round(Number(localStorage.getItem('dvw:font-size')));return Number.isFinite(saved)&&saved>=11&&saved<=20?saved:14});const [tabMenu,setTabMenu]=useState<{x:number;y:number;path:string}>()
|
|
304
418
|
const tabsRef=useRef(tabs);tabsRef.current=tabs
|
|
305
419
|
const tabListRef=useRef<HTMLDivElement>(null)
|
|
306
|
-
const workspaceRequests=useRef(new WorkspaceRequestScope());const loadingTabs=useRef(new Map<string, symbol>())
|
|
420
|
+
const workspaceRequests=useRef(new WorkspaceRequestScope());const loadedWorkspaceCwd=useRef<string>();const loadingTabs=useRef(new Map<string, symbol>())
|
|
307
421
|
const dshDark = useDshDarkTheme()
|
|
308
422
|
const [theme,setTheme]=useState<WorkbenchTheme>(()=>{const saved=localStorage.getItem('dvw:theme');return saved==='dark'||saved==='light'?saved:'dsh'})
|
|
309
423
|
const colorScheme: 'dark' | 'light' = theme === 'dsh' ? dshDark ? 'dark' : 'light' : theme
|
|
310
424
|
useEffect(()=>localStorage.setItem('dvw:theme',theme),[theme])
|
|
425
|
+
useEffect(()=>{if(!snapshot.visible||theme==='dsh')return;const body=document.body;const root=document.documentElement;const wasDark=body.hasAttribute('data-ds-dark-theme');const previousColorScheme=root.style.colorScheme;body.toggleAttribute('data-ds-dark-theme',theme==='dark');root.style.colorScheme=theme;return()=>{body.toggleAttribute('data-ds-dark-theme',wasDark);if(previousColorScheme==='')root.style.removeProperty('color-scheme');else root.style.colorScheme=previousColorScheme}},[snapshot.visible,theme])
|
|
311
426
|
useEffect(()=>localStorage.setItem('dvw:outline',outlineVisible?'open':'closed'),[outlineVisible])
|
|
312
427
|
useEffect(()=>localStorage.setItem('dvw:dsh-chat-visible',chatVisible?'open':'closed'),[chatVisible])
|
|
313
428
|
useEffect(()=>localStorage.setItem('dvw:dsh-chat-width',String(Math.round(chatWidth))),[chatWidth])
|
|
314
429
|
useEffect(()=>localStorage.setItem('dvw:primary-side-visible',primarySideVisible?'open':'closed'),[primarySideVisible])
|
|
315
430
|
useEffect(()=>localStorage.setItem('dvw:status-bar-visible',statusVisible?'open':'closed'),[statusVisible])
|
|
316
|
-
useEffect(()=>localStorage.setItem('dvw:
|
|
317
|
-
useEffect(()=>{const
|
|
431
|
+
useEffect(()=>localStorage.setItem('dvw:font-size',String(fontSize)),[fontSize])
|
|
432
|
+
useEffect(()=>{if(tabMenu===undefined)return;const close=()=>setTabMenu(undefined);const escape=(event:KeyboardEvent)=>{if(event.key==='Escape')close()};window.addEventListener('pointerdown',close);window.addEventListener('keydown',escape);return()=>{window.removeEventListener('pointerdown',close);window.removeEventListener('keydown',escape)}},[tabMenu])
|
|
318
433
|
useEffect(() => {
|
|
319
434
|
if (!snapshot.visible) return
|
|
320
435
|
document.body.setAttribute('data-dvw-workbench-active', '')
|
|
321
436
|
return () => document.body.removeAttribute('data-dvw-workbench-active')
|
|
322
437
|
}, [snapshot.visible])
|
|
323
438
|
useEffect(()=>{if(!snapshot.visible)return;const onKey=(event:KeyboardEvent)=>{if(event.isComposing||!event.ctrlKey||!event.altKey||event.shiftKey||event.code!=='KeyB')return;event.preventDefault();setChatVisible(value=>!value)};window.addEventListener('keydown',onKey,true);return()=>window.removeEventListener('keydown',onKey,true)},[snapshot.visible])
|
|
324
|
-
|
|
439
|
+
// The conversation may still be attaching after the chat column opens, so retry for about a second.
|
|
440
|
+
const addToDsh=useCallback((reference:string)=>{setChatVisible(true);let frames=0;const attempt=()=>{void insertIntoDshComposer(reference).then(done=>{if(done)return;if(++frames<60){requestAnimationFrame(attempt);return}alert('DSH 对话输入框当前不可用,请先选择会话或等待当前回复结束。')})};requestAnimationFrame(attempt)},[])
|
|
441
|
+
const addEntryToDsh=useCallback((path:string,directory:boolean)=>addToDsh(dshReference(path,{directory})),[addToDsh])
|
|
442
|
+
const openForWorkspace=(path:string,line?:number,column?:number,highlight?:string)=>{const existing=tabsRef.current.find(tab=>tab.path===path);if(existing)setTabs(rows=>rows.map(tab=>tab.path===path?{...tab,line,column,highlight,preview:line!==undefined?false:tab.preview}:tab));else setTabs(rows=>[...rows,{...unloadedTab(path),line,column,highlight,preview:line!==undefined?false:undefined}]);setActive(path)}
|
|
325
443
|
useEffect(() => {
|
|
326
444
|
if (sessionId === undefined) return
|
|
445
|
+
// Switching sessions inside one workspace must keep the explorer, tabs and layout as they are.
|
|
446
|
+
if (loadedWorkspaceCwd.current !== undefined && loadedWorkspaceCwd.current === cwd) return
|
|
327
447
|
const epoch = workspaceRequests.current.begin()
|
|
328
448
|
const cleared = resetWorkspaceDocuments<Tab>()
|
|
329
449
|
loadingTabs.current.clear(); setWorkspace(undefined); setTabs(cleared.tabs)
|
|
330
450
|
tabsRef.current = cleared.tabs; setActive(cleared.active); setError(cleared.error)
|
|
331
451
|
const controller = new AbortController()
|
|
332
452
|
const load = async () => {
|
|
333
|
-
|
|
334
|
-
try {
|
|
453
|
+
try {
|
|
335
454
|
const value = await api<{ cwd: string; name: string }>('workspace', { sessionId }, controller.signal)
|
|
336
455
|
if (!workspaceRequests.current.isCurrent(epoch)) return
|
|
337
|
-
setWorkspace(value)
|
|
456
|
+
setWorkspace(value);loadedWorkspaceCwd.current=value.cwd
|
|
338
457
|
let restored: Tab[] = []
|
|
339
458
|
let selected: string | undefined
|
|
340
459
|
try {
|
|
@@ -353,25 +472,45 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
353
472
|
setTabs(restored)
|
|
354
473
|
setActive(restored.some(tab => tab.path === selected) ? selected : restored.at(-1)?.path)
|
|
355
474
|
return
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (delay === undefined) { setError(String(error)); return }
|
|
360
|
-
await new Promise(resolve => setTimeout(resolve, delay))
|
|
361
|
-
if (controller.signal.aborted || !workspaceRequests.current.isCurrent(epoch)) return
|
|
362
|
-
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (controller.signal.aborted || !workspaceRequests.current.isCurrent(epoch)) return
|
|
477
|
+
setError(String(error))
|
|
363
478
|
}
|
|
364
479
|
}
|
|
365
480
|
void load()
|
|
366
481
|
return () => { controller.abort(); loadingTabs.current.clear(); workspaceRequests.current.invalidate(epoch) }
|
|
367
482
|
}, [sessionId, cwd])
|
|
483
|
+
// Drafts are serialised whole, so typing schedules one write instead of one per
|
|
484
|
+
// keystroke; the pending write is flushed when the page hides or the workbench unmounts.
|
|
485
|
+
const persistWorkspace = useRef<() => void>()
|
|
368
486
|
useEffect(() => {
|
|
369
487
|
if (!workspace || sessionId === undefined) return
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
488
|
+
const persist = () => {
|
|
489
|
+
persistWorkspace.current = undefined
|
|
490
|
+
try {
|
|
491
|
+
localStorage.setItem(`dvw:${workspace.cwd}`, JSON.stringify({ view: snapshot.view, paths: tabs.filter(t => !t.path.startsWith('diff:')).map(t => t.path), active, panelHeight, sideWidth }))
|
|
492
|
+
localStorage.setItem(`dvw:drafts:${sessionId}:${workspace.cwd}`, JSON.stringify(tabs.filter(tab => tab.dirty).map(({ path, content, draft, revision }) => ({ path, content, draft, revision }))))
|
|
493
|
+
} catch { setError('无法保存工作区草稿,请在离开页面前保存文件。') }
|
|
494
|
+
}
|
|
495
|
+
persistWorkspace.current = persist
|
|
496
|
+
const timer = setTimeout(persist, 300)
|
|
497
|
+
return () => clearTimeout(timer)
|
|
374
498
|
}, [workspace, sessionId, snapshot.view, tabs, active, panelHeight, sideWidth])
|
|
499
|
+
useEffect(() => {
|
|
500
|
+
const flush = () => persistWorkspace.current?.()
|
|
501
|
+
window.addEventListener('pagehide', flush)
|
|
502
|
+
return () => { window.removeEventListener('pagehide', flush); flush() }
|
|
503
|
+
}, [])
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (!snapshot.visible) return
|
|
506
|
+
if (typeof requestIdleCallback !== 'function') { const timer = setTimeout(preloadMonaco, 200); return () => clearTimeout(timer) }
|
|
507
|
+
const handle = requestIdleCallback(preloadMonaco, { timeout: 2000 })
|
|
508
|
+
return () => cancelIdleCallback(handle)
|
|
509
|
+
}, [snapshot.visible])
|
|
510
|
+
const openTabPaths = tabs.filter(tab => !tab.path.startsWith('diff:')).map(tab => tab.path).join('\0')
|
|
511
|
+
useEffect(() => {
|
|
512
|
+
if (workspace !== undefined) retainEditorModels(openTabPaths === '' ? [] : openTabPaths.split('\0').map(path => `${workspace.cwd}/${path}`))
|
|
513
|
+
}, [workspace, openTabPaths])
|
|
375
514
|
const loadTab = useCallback((path: string) => {
|
|
376
515
|
if (sessionId === undefined) return
|
|
377
516
|
const epoch = workspaceRequests.current.current()
|
|
@@ -399,8 +538,6 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
399
538
|
useEffect(() => {
|
|
400
539
|
if (!snapshot.visible || sessionId === undefined || workspace === undefined) return
|
|
401
540
|
const query = new URLSearchParams({ sessionId })
|
|
402
|
-
const ignores = appliedWatchIgnores.trim()
|
|
403
|
-
if (ignores !== '') query.set('ignore', ignores)
|
|
404
541
|
const url = websocketUrl(`/dsh-vscode/ws/files?${query.toString()}`)
|
|
405
542
|
if (url === undefined) return
|
|
406
543
|
const ws = new WebSocket(url)
|
|
@@ -418,7 +555,7 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
418
555
|
try { const value = JSON.parse(String(event.data)) as { paths?: string[] }; if (Array.isArray(value.paths) && value.paths.every(path => typeof path === 'string')) refresh(value.paths) } catch { /* Ignore invalid notifications. */ }
|
|
419
556
|
}
|
|
420
557
|
return () => { closed = true; ws.close() }
|
|
421
|
-
}, [snapshot.visible, sessionId, workspace, loadTab
|
|
558
|
+
}, [snapshot.visible, sessionId, workspace, loadTab])
|
|
422
559
|
useEffect(() => {
|
|
423
560
|
if (sessionId === undefined || active === undefined || workspace === undefined) return
|
|
424
561
|
const tab = tabsRef.current.find(row => row.path === active)
|
|
@@ -432,7 +569,79 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
432
569
|
const tabRect = selected.getBoundingClientRect()
|
|
433
570
|
list.scrollLeft = tabScrollLeft(list.scrollLeft, listRect, tabRect)
|
|
434
571
|
}, [active, tabs.length])
|
|
435
|
-
const open=(path:string,line?:number,column?:number)=>{if(sessionId!==undefined)openForWorkspace(path,line,column)}
|
|
572
|
+
const open=(path:string,line?:number,column?:number,highlight?:string)=>{if(sessionId!==undefined)openForWorkspace(path,line,column,highlight)}
|
|
573
|
+
// Stable handlers let the memoised side views skip re-rendering while the editor types.
|
|
574
|
+
const openRef=useRef(open);openRef.current=open
|
|
575
|
+
const openPath=useCallback((path:string,line?:number,column?:number,highlight?:string)=>openRef.current(path,line,column,highlight),[])
|
|
576
|
+
// Offer to install the language service that handles the file being opened.
|
|
577
|
+
useEffect(()=>{
|
|
578
|
+
if(active===undefined||active.startsWith('diff:'))return
|
|
579
|
+
const id=languageServerIdForPath(active)
|
|
580
|
+
if(id===undefined||dismissedLsp.current.has(id))return
|
|
581
|
+
if(id==='typescript'){ if(!tsIntellisense)setLspPrompt({id:'typescript',name:'TypeScript / JavaScript',builtin:true,installable:true}); return }
|
|
582
|
+
const server=lspServers.find(value=>value.id===id)
|
|
583
|
+
if(server===undefined||server.installed)return
|
|
584
|
+
setLspPrompt({id:server.id,name:server.name,builtin:false,installable:server.installable,hint:server.hint})
|
|
585
|
+
},[active,lspServers,tsIntellisense])
|
|
586
|
+
const dismissLspPrompt=()=>{ if(lspPrompt!==undefined)dismissedLsp.current.add(lspPrompt.id); setLspPrompt(undefined) }
|
|
587
|
+
const installLspPrompt=async()=>{
|
|
588
|
+
const prompt=lspPrompt; if(prompt===undefined)return
|
|
589
|
+
setLspPrompt(undefined)
|
|
590
|
+
if(prompt.builtin){setTsIntellisense(true);return}
|
|
591
|
+
if(!prompt.installable){setSettingsOpen(true);return}
|
|
592
|
+
await changeLsp(prompt.id,'install')
|
|
593
|
+
}
|
|
594
|
+
const [switcherOpen,setSwitcherOpen]=useState(false);const [switcherChoices,setSwitcherChoices]=useState<WorkspaceChoice[]>([]);const [switcherBusy,setSwitcherBusy]=useState(false);const [switcherError,setSwitcherError]=useState<string>()
|
|
595
|
+
const openSwitcher=useCallback(()=>{setSwitcherError(undefined);setSwitcherChoices(navigation?.choices(sessionId)??[]);setSwitcherOpen(true)},[navigation,sessionId])
|
|
596
|
+
const switchSession=useCallback((id:string)=>{navigation?.openSession(id);setSwitcherOpen(false)},[navigation])
|
|
597
|
+
const enterWorkspace=useCallback((id:string)=>{navigation?.openWorkspace(id);setSwitcherOpen(false)},[navigation])
|
|
598
|
+
const createWorkspace=useCallback(async()=>{
|
|
599
|
+
if(sessionId===undefined)return
|
|
600
|
+
setSwitcherBusy(true);setSwitcherError(undefined)
|
|
601
|
+
try{
|
|
602
|
+
const result=await api<{opened:boolean;workspace?:{id:string}}>('workspace.open',{sessionId})
|
|
603
|
+
if(!result.opened){setSwitcherBusy(false);return}
|
|
604
|
+
const id=result.workspace?.id
|
|
605
|
+
// The client store receives the new workspace asynchronously; wait briefly so "enter" resolves it.
|
|
606
|
+
for(let attempt=0;attempt<12&&navigation!==undefined;attempt++){
|
|
607
|
+
const choices=navigation.choices(sessionId);setSwitcherChoices(choices)
|
|
608
|
+
if(choices.some(choice=>choice.id===id))break
|
|
609
|
+
await new Promise(resolve=>setTimeout(resolve,250))
|
|
610
|
+
}
|
|
611
|
+
if(id!==undefined)navigation?.openWorkspace(id)
|
|
612
|
+
setSwitcherOpen(false)
|
|
613
|
+
}catch(error){setSwitcherError(error instanceof Error?error.message:String(error))}
|
|
614
|
+
finally{setSwitcherBusy(false)}
|
|
615
|
+
},[navigation,sessionId])
|
|
616
|
+
const startLsp=useCallback(async(list:LspServerStatus[])=>{
|
|
617
|
+
if(!snapshot.visible||sessionId===undefined||workspace===undefined)return
|
|
618
|
+
if(!list.some(server=>server.installed)){lspManager.current?.dispose();lspManager.current=undefined;return}
|
|
619
|
+
const monaco=await loadMonaco()
|
|
620
|
+
lspManager.current??=new LspManager(monaco,sessionId,workspace.cwd)
|
|
621
|
+
setLspError((await lspManager.current.sync(list)).join('\n'))
|
|
622
|
+
},[snapshot.visible,sessionId,workspace])
|
|
623
|
+
const refreshLsp=useCallback(async()=>{if(sessionId===undefined)return;try{const list=await api<LspServerStatus[]>('lsp.status',{sessionId});setLspServers(list);await startLsp(list)}catch(error){setLspError(error instanceof Error?error.message:String(error))}},[sessionId,startLsp])
|
|
624
|
+
const changeLsp=useCallback(async(id:string,action:'install'|'uninstall')=>{if(sessionId===undefined)return;setLspBusy(id);setLspError('');try{const list=await api<LspServerStatus[]>(`lsp.${action}`,{sessionId,id});setLspServers(list);await startLsp(list)}catch(error){setLspError(error instanceof Error?error.message:String(error))}finally{setLspBusy(undefined)}},[sessionId,startLsp])
|
|
625
|
+
useEffect(()=>{if(!snapshot.visible||sessionId===undefined||workspace===undefined)return;void refreshLsp()},[snapshot.visible,sessionId,workspace,refreshLsp])
|
|
626
|
+
useEffect(()=>()=>{lspManager.current?.dispose()},[])
|
|
627
|
+
useEffect(()=>{try{localStorage.setItem(TS_INTELLISENSE_KEY,tsIntellisense?'on':'off')}catch{}},[tsIntellisense])
|
|
628
|
+
useEffect(()=>{
|
|
629
|
+
if(!snapshot.visible||sessionId===undefined||workspace===undefined)return
|
|
630
|
+
// Not installed: never load Monaco just for this, only release an index a previous install built.
|
|
631
|
+
if(!tsIntellisense){if(window.monaco!==undefined)disposeTypeScriptProject(window.monaco);setTsIndex({state:'idle'});return}
|
|
632
|
+
let cancelled=false;const cwd=workspace.cwd;const disposables:{dispose():void}[]=[]
|
|
633
|
+
void loadMonaco().then(monaco=>{
|
|
634
|
+
if(cancelled)return
|
|
635
|
+
setTsIndex({state:'loading'})
|
|
636
|
+
disposables.push(monaco.editor.registerEditorOpener({openCodeEditor:(_source,resource,selection)=>{const path=workspacePathFromModel(cwd,resource.toString());if(path===undefined)return false;const position=selection===undefined?undefined:'startLineNumber' in selection?{line:selection.startLineNumber,column:selection.startColumn}:{line:selection.lineNumber,column:selection.column};openPath(path,position?.line,position?.column);return true}}))
|
|
637
|
+
const changed=(event:Event)=>{const paths=(event as CustomEvent<string[]|undefined>).detail;if(Array.isArray(paths))void refreshIndexedFiles(monaco,sessionId,cwd,paths)}
|
|
638
|
+
window.addEventListener('dvw-files-changed',changed);disposables.push({dispose:()=>window.removeEventListener('dvw-files-changed',changed)})
|
|
639
|
+
void indexTypeScriptProject(monaco,sessionId,cwd).then(result=>{if(!cancelled)setTsIndex({state:'ready',...result})}).catch(error=>{if(!cancelled)setTsIndex({state:'error',error:error instanceof Error?error.message:String(error)})})
|
|
640
|
+
})
|
|
641
|
+
return()=>{cancelled=true;for(const disposable of disposables)disposable.dispose()}
|
|
642
|
+
},[snapshot.visible,sessionId,workspace,tsIntellisense,openPath])
|
|
643
|
+
const openDiff=useCallback((path:string,diff:GitDiff,title?:string,diffSourcePath?:string)=>{const key=`diff:${path}`;const source=diffSourcePath??path;// Previewable documents open like VS Code's PDF/Office editors; history diffs keep the binary notice.
|
|
644
|
+
if(isPreviewDocumentPath(source)&&!path.startsWith('commit:')){openPath(source);return}const binary=isBinaryDiffPath(source);setTabs(rows=>[...rows.filter(row=>row.path!==key),{path:key,title,content:diff.diff,draft:diff.diff,revision:'',size:diff.diff.length,binary,tooLarge:false,dirty:false,diff:binary?undefined:diff,diffMode:'inline',diffSourcePath:source}]);setActive(key)},[openPath])
|
|
436
645
|
useEffect(()=>{const listener=(event:Event)=>void open((event as CustomEvent<string>).detail);window.addEventListener('dvw-open-workspace-path',listener);return()=>window.removeEventListener('dvw-open-workspace-path',listener)},[sessionId,tabs])
|
|
437
646
|
const savingTabs = useRef(new Set<string>())
|
|
438
647
|
const save = async (tab: Tab) => {
|
|
@@ -449,29 +658,63 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
449
658
|
finally { savingTabs.current.delete(tab.path) }
|
|
450
659
|
}
|
|
451
660
|
const close=(tab:Tab)=>{if(tab.dirty&&!confirm(`${tab.path} 有未保存更改,确定关闭?`))return;loadingTabs.current.delete(tab.path);setTabs(rows=>rows.filter(row=>row.path!==tab.path));if(active===tab.path)setActive(tabs.filter(row=>row.path!==tab.path).at(-1)?.path)}
|
|
452
|
-
const closeAll=()=>{const dirty=tabs.filter(tab=>tab.dirty);if(dirty.length>0&&!confirm(`有 ${dirty.length} 个文件包含未保存更改,确定全部关闭?`))return;loadingTabs.current.clear();setTabs([]);setActive(undefined);setError(undefined)}
|
|
661
|
+
const closeAll=()=>{const dirty=tabs.filter(tab=>tab.dirty);if(dirty.length>0&&!confirm(`有 ${dirty.length} 个文件包含未保存更改,确定全部关闭?`))return;loadingTabs.current.clear();setTabs([]);setActive(undefined);setError(undefined);setTabMenu(undefined)}
|
|
662
|
+
const removeTabs=(targets:Tab[],verb:string)=>{if(targets.length===0)return;const dirty=targets.filter(tab=>tab.dirty).length;if(dirty>0&&!confirm(`有 ${dirty} 个文件包含未保存更改,确定${verb}?`))return;const paths=new Set(targets.map(tab=>tab.path));for(const path of paths)loadingTabs.current.delete(path);const remaining=tabsRef.current.filter(tab=>!paths.has(tab.path));setTabs(remaining);if(active!==undefined&&paths.has(active))setActive(remaining.at(-1)?.path);setTabMenu(undefined)}
|
|
453
663
|
const activeTab=tabs.find(tab=>tab.path===active); const Panel=panels.find(panel=>panel.id===snapshot.bottomPanel)?.component; const terminalAvailable=panels.some(panel=>panel.id==='terminal')
|
|
664
|
+
const menuTab=tabMenu===undefined?undefined:tabs.find(tab=>tab.path===tabMenu.path); const menuIndex=menuTab===undefined?-1:tabs.findIndex(tab=>tab.path===menuTab.path); const savedTabs=tabs.filter(tab=>!tab.dirty)
|
|
454
665
|
const selectView=(view:WorkbenchView)=>{if(snapshot.view===view&&primarySideVisible){setPrimarySideVisible(false);return}setPrimarySideVisible(true);service.show(view)}
|
|
666
|
+
const ACTIVITY_IDS=['explorer','search','git','terminal','chat'] as const
|
|
667
|
+
const [activityOrder,setActivityOrder]=useState<string[]>(()=>normalizeActivityOrder(readActivityJSON('dvw:activity-order'),ACTIVITY_IDS))
|
|
668
|
+
const [activityHidden,setActivityHidden]=useState<string[]>(()=>{const value=readActivityJSON('dvw:activity-hidden');return Array.isArray(value)?value.filter((id):id is string=>typeof id==='string'&&(ACTIVITY_IDS as readonly string[]).includes(id)):[]})
|
|
669
|
+
useEffect(()=>localStorage.setItem('dvw:activity-order',JSON.stringify(activityOrder)),[activityOrder])
|
|
670
|
+
useEffect(()=>localStorage.setItem('dvw:activity-hidden',JSON.stringify(activityHidden)),[activityHidden])
|
|
671
|
+
useEffect(()=>{
|
|
672
|
+
if(activityHidden.includes('chat')&&chatVisible)setChatVisible(false)
|
|
673
|
+
if(activityHidden.includes('terminal')&&snapshot.bottomPanel==='terminal')toggleTerminal(service)
|
|
674
|
+
if(primarySideVisible&&(snapshot.view==='explorer'||snapshot.view==='search'||snapshot.view==='git')&&activityHidden.includes(snapshot.view))setPrimarySideVisible(false)
|
|
675
|
+
},[activityHidden])
|
|
455
676
|
const selectHeading=(index:number)=>{if(activeTab===undefined)return;setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:true}:tab));window.setTimeout(()=>window.dispatchEvent(new CustomEvent('dvw-markdown-heading',{detail:index})),50)}
|
|
456
677
|
const suppressAttachmentDrop=(event:DragEvent)=>{if(!event.dataTransfer.types.includes('Files'))return;const target=event.target;const insideTree=target instanceof Element&&target.closest('.dvw-tree')!==null;if(!insideTree){event.preventDefault();event.stopPropagation()}}
|
|
457
678
|
if(!snapshot.visible)return null
|
|
458
|
-
if(sessionId===undefined)return <div className="dvw-overlay" data-dvw-theme={
|
|
679
|
+
if(sessionId===undefined)return <div className="dvw-overlay" data-dvw-theme={colorScheme}><WorkbenchTitlebar/><div className="dvw-empty" style={{gridColumn:'1/4'}}><div>请先选择或创建一个 DSH 会话。<br/><button onClick={()=>service.hide()}>返回 DSH</button></div></div></div>
|
|
459
680
|
const markdown=activeTab!==undefined&&!activeTab.path.startsWith('diff:')&&/\.md$/i.test(activeTab.path)
|
|
460
681
|
const diffMarkdown=activeTab?.diff!==undefined&&/\.md$/i.test(activeTab.diffSourcePath??'')
|
|
461
682
|
const markdownPreview=markdown||(diffMarkdown&&activeTab?.preview===true)
|
|
462
683
|
const headings=markdownPreview&&activeTab?markdownHeadings(activeTab.diff?.modified??activeTab.draft):[]
|
|
463
|
-
return <div className="dvw-overlay" data-dvw-theme={
|
|
684
|
+
return <div className="dvw-overlay" data-dvw-theme={colorScheme} data-dvw-chat={chatVisible||undefined} data-dvw-primary-side={primarySideVisible||undefined} data-dvw-status={statusVisible||undefined} data-dvw-bottom-panel={snapshot.bottomPanel||undefined} style={{'--dvw-side-width':`${sideWidth}px`,'--dvw-chat-width':`${chatWidth}px`} as CSSProperties} role="dialog" aria-label="VS Code 工作台" onDragEnterCapture={suppressAttachmentDrop} onDragOverCapture={suppressAttachmentDrop} onDropCapture={suppressAttachmentDrop}>
|
|
464
685
|
<WorkbenchTitlebar name={workspace?.name}/>
|
|
465
|
-
<DshConversationPanel visible={chatVisible&&!settingsOpen} width={chatWidth} statusHeight={statusVisible?
|
|
466
|
-
<DshConversationTheme visible={chatVisible} colorScheme={colorScheme} followDsh={
|
|
467
|
-
{
|
|
468
|
-
{
|
|
469
|
-
{
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
686
|
+
<DshConversationPanel visible={chatVisible&&!settingsOpen} width={chatWidth} statusHeight={statusVisible?30:6}/>
|
|
687
|
+
<DshConversationTheme visible={chatVisible} colorScheme={colorScheme} followDsh={false}/>
|
|
688
|
+
{chatVisible&&<aside className="dvw-chat"><PanelResizer className="dvw-chat-resizer" label="拖动调整 DSH 对话栏宽度" width={chatWidth} min={400} max={720} defaultWidth={420} direction={-1} hideBelow={200} onHide={()=>setChatVisible(false)} onWidth={setChatWidth}/><header className="dvw-chat-head"><span><DshMark/> DSH</span><button className="dvw-icon" title="关闭 DSH 对话栏" onClick={()=>setChatVisible(false)}><Icon name="close"/></button></header><div className="dvw-chat-body"/></aside>}
|
|
689
|
+
{lspPrompt!==undefined&&<div className="dvw-confirm-backdrop" role="presentation" onMouseDown={dismissLspPrompt}><section className="dvw-confirm" role="alertdialog" aria-modal="true" aria-label="语言未安装" onMouseDown={event=>event.stopPropagation()}><h2>语言未安装</h2><p>{lspPrompt.name} 的语言服务尚未安装。</p><p className="dvw-confirm-note">{lspPrompt.installable?'安装后才能使用转到定义、查找引用、Peek、智能补全和签名帮助。':lspPrompt.hint??'该语言服务器需要手动安装。'}</p><footer><button onClick={dismissLspPrompt}>以后再说</button><button className="dvw-primary-button" onClick={()=>void installLspPrompt()}>{lspPrompt.installable?'安装':'打开设置'}</button></footer></section></div>}
|
|
690
|
+
{switcherOpen&&<WorkspaceSwitcher choices={switcherChoices} activeSessionId={sessionId} unavailable={navigation?.available===false} busy={switcherBusy} error={switcherError} onCreate={()=>void createWorkspace()} onSwitch={switchSession} onOpenWorkspace={enterWorkspace} onClose={()=>setSwitcherOpen(false)}/>}
|
|
691
|
+
{settingsOpen&&<div className="dvw-settings-backdrop" role="presentation" onMouseDown={()=>setSettingsOpen(false)}><section className="dvw-settings" role="dialog" aria-modal="true" aria-label="工作台设置" onMouseDown={event=>event.stopPropagation()}><header><span><Icon name="settings"/> 工作台设置</span><button className="dvw-icon" title="关闭设置" onClick={()=>setSettingsOpen(false)}><Icon name="close"/></button></header><div className="dvw-settings-body"><section><h3>外观</h3><label>颜色主题</label><div className="dvw-settings-themes">{([['dsh','跟随 DSH'],['dark','深色'],['light','浅色']] as const).map(([value,label])=><button key={value} data-active={theme===value} onClick={()=>setTheme(value)}><span className={`dvw-theme-swatch ${value}`}/><span>{label}</span>{theme===value&&<Icon name="check"/>}</button>)}</div><FontSizeControl value={fontSize} onChange={setFontSize}/></section><section><h3>布局</h3><label className="dvw-setting-check"><input type="checkbox" checked={primarySideVisible} onChange={event=>setPrimarySideVisible(event.target.checked)}/><span>显示左侧视图</span></label><label className="dvw-setting-check"><input type="checkbox" checked={chatVisible} onChange={event=>setChatVisible(event.target.checked)}/><span>显示 DSH 对话栏</span></label><label className="dvw-setting-check"><input type="checkbox" checked={statusVisible} onChange={event=>setStatusVisible(event.target.checked)}/><span>显示底部状态栏</span></label><label className="dvw-setting-range"><span>左侧视图宽度</span><input type="range" min="210" max="640" step="1" value={Math.round(sideWidth)} onChange={event=>setSideWidth(Math.round(Number(event.target.value)))}/><output>{sideWidth}px</output></label><label className="dvw-setting-range"><span>DSH 对话栏宽度</span><input type="range" min="400" max="720" step="1" value={Math.round(chatWidth)} onChange={event=>setChatWidth(Math.max(400,Math.round(Number(event.target.value))))}/><output>{chatWidth}px</output></label></section><section><h3>语言智能</h3><div className="dvw-setting-extension"><div><strong>TypeScript / JavaScript</strong><span>内置 · {tsIntellisense ? tsIndex.state === 'loading' ? '正在索引工作区…' : tsIndex.state === 'error' ? `索引失败:${tsIndex.error}` : tsIndex.state === 'ready' ? `已索引 ${tsIndex.files} 个文件${tsIndex.truncated ? '(已达上限)' : ''}` : '已安装,转到定义、查找引用、Peek、补全、签名帮助' : '点击安装'}</span></div><button className={tsIntellisense?undefined:'dvw-primary-button'} onClick={()=>setTsIntellisense(value=>!value)}>{tsIntellisense?'卸载':'安装'}</button></div>{lspServers.map(server=><div className="dvw-setting-extension" key={server.id}><div><strong>{server.name}</strong><span>{server.installed?'已安装':server.installable?'点击安装':'需要手动安装'}{!server.installed&&server.hint?` · ${server.hint}`:''}</span></div>{server.installable?<button disabled={lspBusy===server.id} className={server.installed?undefined:'dvw-primary-button'} onClick={()=>void changeLsp(server.id,server.installed?'uninstall':'install')}>{lspBusy===server.id?'处理中…':server.installed?'卸载':'安装'}</button>:<button onClick={()=>void refreshLsp()}>重新检测</button>}</div>)}<p className="dvw-setting-hint">每种语言使用该生态里最活跃的语言服务器,安装只写入本插件的私有目录。安装后在编辑器中 F12 转到定义、Shift+F12 查找引用、Alt+F12 速览、Ctrl+Space 补全,并显示诊断。</p>{lspError&&<p className="dvw-setting-hint">{lspError}</p>}</section></div></section></div>}
|
|
692
|
+
<ActivityBar
|
|
693
|
+
views={[
|
|
694
|
+
{id:'explorer',label:'资源管理器',icon:<Icon name="files"/>,active:primarySideVisible&&snapshot.view==='explorer',onActivate:()=>selectView('explorer')},
|
|
695
|
+
{id:'search',label:'搜索',icon:<Icon name="search"/>,active:primarySideVisible&&snapshot.view==='search',onActivate:()=>selectView('search')},
|
|
696
|
+
{id:'git',label:'源代码管理',icon:<Icon name="source-control"/>,active:primarySideVisible&&snapshot.view==='git',onActivate:()=>selectView('git')},
|
|
697
|
+
{id:'terminal',label:terminalAvailable?(snapshot.bottomPanel==='terminal'?'隐藏终端':'显示终端'):'终端适配器未安装',icon:<Icon name="terminal"/>,active:snapshot.bottomPanel==='terminal',disabled:!terminalAvailable,onActivate:()=>toggleTerminal(service)},
|
|
698
|
+
{id:'chat',label:chatVisible?'关闭 DSH 对话栏 (Ctrl+Alt+B)':'显示 DSH 对话栏 (Ctrl+Alt+B)',icon:<DshMark size={18}/>,active:chatVisible,onActivate:()=>setChatVisible(value=>!value)},
|
|
699
|
+
]}
|
|
700
|
+
fixed={[{id:'workspaces',label:'打开工作区 / 切换会话',icon:<Icon name="folder-opened"/>,active:false,onActivate:openSwitcher}]}
|
|
701
|
+
bottom={[
|
|
702
|
+
{id:'home',label:'返回 DSH',icon:<Icon name="home"/>,active:false,onActivate:()=>service.hide()},
|
|
703
|
+
{id:'settings',label:'管理工作台设置',icon:<Icon name="settings"/>,active:settingsOpen,onActivate:()=>setSettingsOpen(true)},
|
|
704
|
+
]}
|
|
705
|
+
order={activityOrder} hidden={activityHidden}
|
|
706
|
+
onOrderChange={setActivityOrder} onHiddenChange={setActivityHidden}/>
|
|
707
|
+
<aside className="dvw-side" data-side-width={sideWidth}>{snapshot.view==='explorer'&&workspace!==undefined&&<ExplorerView sessionId={sessionId} cwd={workspace.cwd} selected={active} onOpen={openPath} onAddToDsh={addEntryToDsh}/>} {snapshot.view==='search'&&<SearchView sessionId={sessionId} onOpen={openPath}/>} {snapshot.view==='git'&&<GitView sessionId={sessionId} onDiff={openDiff}/>}</aside>
|
|
708
|
+
<PanelResizer className="dvw-side-resizer" label="拖动调整源码管理侧边栏宽度" width={sideWidth} min={210} max={640} defaultWidth={210} direction={1} hideBelow={105} onHide={()=>setPrimarySideVisible(false)} onWidth={setSideWidth}/>
|
|
709
|
+
<main className="dvw-main" onDragOver={event => { if (draggedEntry?.kind !== 'file') return; event.preventDefault(); event.dataTransfer.dropEffect = 'copy' }} onDrop={event => { const entry = draggedEntry; if (entry?.kind !== 'file') return; event.preventDefault(); endEntryDrag(); openPath(entry.path) }}><div className="dvw-tabs" data-empty={tabs.length===0||undefined}><div className="dvw-tab-list" ref={tabListRef}>{tabs.map(tab=><button className="dvw-tab" data-active={tab.path===active} key={tab.path} title={tab.diffSourcePath??tab.path} onClick={()=>setActive(tab.path)} onAuxClick={event=>{if(event.button===1){event.preventDefault();close(tab)}}} onMouseDown={event=>{if(event.button===1)event.preventDefault()}} onContextMenu={event=>{event.preventDefault();setActive(tab.path);setTabMenu({x:event.clientX,y:event.clientY,path:tab.path})}}><FileIcon name={tabLabel(tab)} className="dvw-tab-icon"/><span className="dvw-tab-name">{tab.dirty?'● ':''}{tabLabel(tab)}</span><span className="dvw-tab-close" onClick={event=>{event.stopPropagation();close(tab)}}><Icon name="close"/></span></button>)}</div></div><div className="dvw-editor" data-outline={markdownPreview&&outlineVisible&&headings.length>0}>{error&&<div className="dvw-error"><button className="dvw-icon" onClick={()=>setError(undefined)}><Icon name="close"/></button>{error}</div>}{markdownPreview&&outlineVisible&&headings.length>0&&<MarkdownOutline headings={headings} onClose={()=>setOutlineVisible(false)} onHeading={selectHeading}/>} {markdown&&activeTab&&<div className="dvw-editor-toolbar"><button className="dvw-icon dvw-outline-toggle" data-active={outlineVisible} title={outlineVisible?'关闭 Markdown 大纲':'显示 Markdown 大纲'} onClick={()=>setOutlineVisible(value=>!value)}><Icon name="list-tree"/></button><button className="dvw-mode-button" data-active={!activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:false}:tab))}><Icon name="code"/> 源码</button><button className="dvw-mode-button" data-active={activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:true}:tab))}><Icon name="preview"/> 预览</button></div>}{activeTab?.diff&&<div className="dvw-editor-toolbar dvw-diff-toolbar">{diffMarkdown&&<div className="dvw-diff-preview-switch"><button className="dvw-mode-button" data-diff-view="diff" data-active={!activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:false}:tab))}><Icon name="diff"/> 差异</button><button className="dvw-mode-button" data-diff-view="preview" data-active={activeTab.preview} onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,preview:true}:tab))}><Icon name="preview"/> 预览</button></div>}{!activeTab.preview&&<><button className="dvw-mode-button dvw-diff-mode-button" data-mode="inline" data-active={activeTab.diffMode!=='split'} title="内联视图" onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,diffMode:'inline'}:tab))}><Icon name="diff-single"/> 内联</button><button className="dvw-mode-button dvw-diff-mode-button" data-mode="split" data-active={activeTab.diffMode==='split'} title="并排视图" onClick={()=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,diffMode:'split'}:tab))}><Icon name="diff-sidebyside"/> 并排</button></>}</div>}<div className="dvw-editor-body" data-toolbar={markdown||activeTab?.diff!==undefined}>{activeTab===undefined?<div className="dvw-empty"><div><strong>DSH Workbench</strong><br/>从资源管理器或搜索结果打开文件</div></div>:activeTab.diff?diffMarkdown&&activeTab.preview?<MarkdownPreview source={activeTab.diff.modified} sessionId={sessionId} path={activeTab.diffSourcePath??''}/>:<MonacoDiffEditor uri={`${workspace?.cwd??''}/${activeTab.path}`} original={activeTab.diff.original} modified={activeTab.diff.modified} mode={activeTab.diffMode??'inline'} theme={colorScheme} fontSize={fontSize}/>:activeTab.imageUrl?<div className="dvw-image-preview"><img src={activeTab.imageUrl} alt={activeTab.path}/><span>{activeTab.path}</span></div>:activeTab.documentPreview&&activeTab.documentUrl?<DocumentPreview kind={activeTab.documentPreview} src={activeTab.documentUrl} path={activeTab.path}/>:markdown&&activeTab.preview?<MarkdownPreview source={activeTab.draft} sessionId={sessionId} path={activeTab.path}/>:activeTab.binary||activeTab.tooLarge?<div className="dvw-message">{activeTab.binary?'二进制文件无法在文本编辑器或差异视图中打开':`文件超过 5 MiB:${activeTab.path}`}{activeTab.binary&&activeTab.diffSourcePath!==undefined&&!activeTab.path.startsWith('diff:commit:')&&<button onClick={()=>void openPath(activeTab.diffSourcePath!)}>打开文件</button>}</div>:activeTab.loaded===false?<div className="dvw-message">正在加载文件…</div>:<MonacoEditor uri={`${workspace?.cwd??''}/${activeTab.path}`} onAddToDsh={selection=>addToDsh(dshReference(activeTab.path,{selection}))} value={activeTab.draft} theme={colorScheme} fontSize={fontSize} line={activeTab.line} column={activeTab.column} highlight={activeTab.highlight} onChange={draft=>setTabs(rows=>rows.map(tab=>tab.path===activeTab.path?{...tab,draft,dirty:draft!==tab.content}:tab))} onSave={()=>void save(activeTab)}/>}</div></div>{snapshot.bottomPanel===undefined&&<div className="dvw-bottom-reveal" title="向上拖动打开终端" onPointerDown={event=>{if(event.button!==0)return;event.preventDefault();service.toggleBottomPanel('terminal');const statusHeight=statusVisible?24:0;const heightAt=(clientY:number)=>Math.max(120,Math.min(window.innerHeight-160,window.innerHeight-clientY-statusHeight));const move=(e:PointerEvent)=>setPanelHeight(heightAt(e.clientY));const up=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up)};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up)}}/>}</main>
|
|
710
|
+
{tabMenu&&menuTab&&<div className="dvw-context-menu dvw-tab-menu" role="menu" style={{left:Math.min(tabMenu.x,window.innerWidth-240),top:Math.min(tabMenu.y,window.innerHeight-180)}} onPointerDown={event=>event.stopPropagation()}>
|
|
711
|
+
<button role="menuitem" onClick={()=>{setTabMenu(undefined);close(menuTab)}}>关闭<span>Ctrl+F4</span></button>
|
|
712
|
+
<button role="menuitem" disabled={tabs.length<=1} onClick={()=>removeTabs(tabs.filter(row=>row.path!==menuTab.path),'关闭其他标签页')}>关闭其他</button>
|
|
713
|
+
<button role="menuitem" disabled={menuIndex<0||menuIndex>=tabs.length-1} onClick={()=>removeTabs(tabs.slice(menuIndex+1),'关闭右侧标签页')}>关闭右侧标签页</button>
|
|
714
|
+
<button role="menuitem" disabled={savedTabs.length===0} onClick={()=>removeTabs(savedTabs,'关闭已保存标签页')}>关闭已保存<span>Ctrl+K U</span></button>
|
|
715
|
+
<button role="menuitem" onClick={closeAll}>全部关闭<span>Ctrl+K W</span></button>
|
|
716
|
+
</div>}
|
|
717
|
+
{snapshot.bottomPanel&&<section className="dvw-bottom" style={{height:panelHeight}}><div className="dvw-bottom-resizer" onPointerDown={event=>{if(event.button!==0)return;event.preventDefault();const startY=event.clientY,startHeight=panelHeight;const move=(e:PointerEvent)=>setPanelHeight(Math.max(120,Math.min(window.innerHeight-160,startHeight+startY-e.clientY)));const up=(e:PointerEvent)=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);if(startHeight+startY-e.clientY<80)service.toggleBottomPanel(snapshot.bottomPanel!)};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up)}}/><div className="dvw-bottom-head"><button className="dvw-bottom-title">{panels.find(p=>p.id===snapshot.bottomPanel)?.title??snapshot.bottomPanel}</button>{snapshot.bottomPanel==='terminal'&&<button className="dvw-icon" title="新建终端" onClick={()=>window.dispatchEvent(new Event('dvw-terminal-new'))}><Icon name="add"/></button>}<button className="dvw-icon" style={{marginLeft:'auto'}} onClick={()=>service.toggleBottomPanel(snapshot.bottomPanel!)}><Icon name="close"/></button></div><div className="dvw-bottom-body">{Panel&&workspace&&<Panel sessionId={sessionId} cwd={workspace.cwd} visible colorScheme={colorScheme}/>}</div></section>}
|
|
718
|
+
<footer className="dvw-status"><span><Icon name="git-branch"/> 工作区</span><span>{workspace?.cwd??cwd??''}</span>{activeTab?.external&&<span>磁盘文件已变化</span>}<button className="dvw-icon" style={{color:'inherit',marginLeft:'auto'}} onClick={()=>service.hide()}>返回 DSH</button></footer>
|
|
476
719
|
</div>
|
|
477
720
|
}
|