dsh-vscode-workbench 0.1.12 → 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 +15 -6
- package/README.md +17 -6
- package/lib/client.js +5118 -728
- package/lib/client.js.map +1 -1
- package/lib/index.js +899 -57
- 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 +27 -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 +12 -0
- package/package.json +21 -17
- 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 +342 -78
- 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 +31 -30
- package/src/client/typescriptProject.ts +81 -0
- package/src/context.ts +8 -0
- package/src/host.ts +100 -15
- package/src/index.ts +123 -12
- package/src/lsp.ts +170 -0
- package/src/office.ts +95 -0
- package/src/types.ts +2 -0
- package/src/watch-ignore.ts +84 -0
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,16 @@ 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
|
+
|
|
104
|
+
/** Return the horizontal scroll offset that keeps an active tab inside the tab strip. */
|
|
105
|
+
export function tabScrollLeft(scrollLeft:number,viewport:{left:number;right:number},tab:{left:number;right:number}):number{
|
|
106
|
+
if(tab.left<viewport.left)return scrollLeft-(viewport.left-tab.left)
|
|
107
|
+
if(tab.right>viewport.right)return scrollLeft+(tab.right-viewport.right)
|
|
108
|
+
return scrollLeft
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
/** Invoke `fn` at most once per `ms` after the latest call, always running the freshest callback. */
|
|
98
112
|
function useDebouncedCallback(fn: () => void, ms: number): () => void {
|
|
99
113
|
const fnRef = useRef(fn); fnRef.current = fn
|
|
@@ -109,7 +123,62 @@ function useDshDarkTheme(): boolean {
|
|
|
109
123
|
return dark
|
|
110
124
|
}
|
|
111
125
|
|
|
112
|
-
|
|
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 }) {
|
|
113
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>()
|
|
114
183
|
const load = useCallback(() => { void api<FileEntry[]>('fs.list', { sessionId, path }).then(value => { setRows(value); setError(undefined) }).catch(error => setError(String(error))) }, [sessionId, path])
|
|
115
184
|
useEffect(load, [load])
|
|
@@ -120,16 +189,21 @@ function Tree({ sessionId, path, depth, expanded, selected, selectedDirectory, s
|
|
|
120
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])
|
|
121
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)) } }
|
|
122
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') }
|
|
123
195
|
const drop = (event: DragEvent, directory: string) => { event.preventDefault(); event.stopPropagation(); setDropTarget(false); if (event.dataTransfer.files.length > 0) onUpload(directory, event.dataTransfer.files) }
|
|
124
|
-
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}>
|
|
125
|
-
<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'
|
|
126
|
-
<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>}
|
|
127
199
|
</div>
|
|
128
|
-
{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 }} />}
|
|
129
|
-
</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>}</>
|
|
130
202
|
}
|
|
131
203
|
|
|
132
|
-
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 }>()
|
|
133
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)
|
|
134
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])
|
|
135
209
|
useEffect(() => { if (props.cwd) localStorage.setItem(`dvw:tree:${props.cwd}`, JSON.stringify([...expanded])) }, [props.cwd, expanded])
|
|
@@ -172,8 +246,8 @@ function Explorer(props: { sessionId: string; cwd?: string; selected?: string; o
|
|
|
172
246
|
}, [upload])
|
|
173
247
|
const drop = (event: DragEvent) => { event.preventDefault(); setDropTarget(false); if (event.dataTransfer.files.length > 0) void upload('', event.dataTransfer.files) }
|
|
174
248
|
const destination = uploadDestination(selectedDirectory)
|
|
175
|
-
const treeDrag = (event: DragEvent) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); event.stopPropagation(); setDropTarget(true) } }
|
|
176
|
-
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></>
|
|
177
251
|
}
|
|
178
252
|
|
|
179
253
|
/** Return the workspace-relative directory used by the file picker. */
|
|
@@ -182,6 +256,16 @@ export function uploadDestination(selectedDirectory?: string): string { return s
|
|
|
182
256
|
/** Join an inline-created entry name to the currently selected Explorer directory. */
|
|
183
257
|
export function createdEntryPath(directory: string, name: string): string { return directory ? `${directory}/${name}` : name }
|
|
184
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
|
+
|
|
185
269
|
/** Replace the final path component while preserving its workspace-relative directory. */
|
|
186
270
|
export function renamedPath(path: string, name: string): string { return [...path.split('/').slice(0, -1), name].filter(Boolean).join('/') }
|
|
187
271
|
|
|
@@ -195,7 +279,7 @@ function MarkdownOutline({headings,onClose,onHeading}:{headings:MarkdownHeading[
|
|
|
195
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>
|
|
196
280
|
}
|
|
197
281
|
|
|
198
|
-
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 }) {
|
|
199
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>()
|
|
200
284
|
const pending = useRef<AbortController>()
|
|
201
285
|
useEffect(() => () => pending.current?.abort(), [])
|
|
@@ -205,7 +289,7 @@ function Search(props: { sessionId: string; onOpen(path: string, line: number, c
|
|
|
205
289
|
for (const match of matches) grouped.set(match.path, [...(grouped.get(match.path) ?? []), match])
|
|
206
290
|
return grouped
|
|
207
291
|
}, [matches])
|
|
208
|
-
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></>
|
|
209
293
|
}
|
|
210
294
|
|
|
211
295
|
export interface GraphTrack { hash: string; color: number }
|
|
@@ -232,31 +316,51 @@ export function graphRows(commits: GitCommit[]): GraphRow[] {
|
|
|
232
316
|
})
|
|
233
317
|
}
|
|
234
318
|
|
|
235
|
-
|
|
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']
|
|
236
325
|
function graphColor(color:number):string{return GRAPH_COLORS[color%GRAPH_COLORS.length]!}
|
|
237
|
-
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)}
|
|
238
328
|
|
|
239
|
-
/**
|
|
329
|
+
/** Move a passing lane to its new column with two 5px arcs around the row midline. */
|
|
240
330
|
export function graphTrackPath(from: number, to: number, startY: number, endY: number, bendY: number): string {
|
|
241
331
|
if (from === to) return `M ${from} ${startY} V ${endY}`
|
|
242
332
|
const direction = Math.sign(to - from)
|
|
243
|
-
const radius = Math.min(
|
|
244
|
-
|
|
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}}/>
|
|
245
349
|
}
|
|
246
350
|
|
|
247
351
|
function GraphTracks({row}:{row:GraphRow}) {
|
|
248
|
-
const width=graphWidth(row);const x=row.lane
|
|
249
|
-
return <svg className="dvw-graph-svg" width={width} height=
|
|
250
|
-
{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
|
|
251
|
-
<path d={`M ${x} 0 V
|
|
252
|
-
{row.parentLanes.map((parent,index)=>{const
|
|
253
|
-
<
|
|
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)}/>
|
|
254
358
|
</svg>
|
|
255
359
|
}
|
|
256
360
|
|
|
257
361
|
function GraphContinuation({row}:{row:GraphRow}) {
|
|
258
362
|
const width=graphWidth(row)
|
|
259
|
-
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>
|
|
260
364
|
}
|
|
261
365
|
|
|
262
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 }) {
|
|
@@ -264,7 +368,7 @@ function CommitGraph({ commits, expanded, files, onToggle, onOpenFile }: { commi
|
|
|
264
368
|
const opened=expanded===row.commit.hash
|
|
265
369
|
return <div className="dvw-graph-entry" data-expanded={opened} key={row.commit.hash}>
|
|
266
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>
|
|
267
|
-
{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>}
|
|
268
372
|
</div>
|
|
269
373
|
})}</div>
|
|
270
374
|
}
|
|
@@ -274,57 +378,82 @@ function GitGroupHeader({ title, count, expanded, onToggle, children }: { title:
|
|
|
274
378
|
}
|
|
275
379
|
|
|
276
380
|
function Git(props: { sessionId: string; onDiff(key:string, diff:GitDiff, title?:string, sourcePath?:string): void }) {
|
|
277
|
-
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>()
|
|
278
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])
|
|
279
383
|
const refreshDebounced = useDebouncedCallback(refresh, 500)
|
|
280
384
|
useEffect(refresh,[refresh]); useEffect(()=>{const fn=()=>refreshDebounced();window.addEventListener('dvw-files-changed',fn);return()=>window.removeEventListener('dvw-files-changed',fn)},[refreshDebounced])
|
|
281
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)} }
|
|
282
|
-
|
|
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)}}
|
|
283
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)}}
|
|
284
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)}}
|
|
285
392
|
const staged=status?.changes.filter(change=>change.index!=='.'&&change.index!=='?')??[]
|
|
286
393
|
const changed=status?.changes.filter(change=>change.worktree!=='.'||change.untracked)??[]
|
|
287
|
-
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)}><
|
|
288
|
-
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>}</>}</>
|
|
289
396
|
}
|
|
290
397
|
|
|
291
|
-
|
|
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
|
+
}
|
|
409
|
+
|
|
410
|
+
const ExplorerView = memo(Explorer)
|
|
411
|
+
const SearchView = memo(Search)
|
|
412
|
+
const GitView = memo(Git)
|
|
292
413
|
|
|
293
|
-
export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbench; sessionId?: string; cwd?: string }) {
|
|
414
|
+
export function Workbench({ service, sessionId, cwd, navigation }: { service: VscodeWorkbench; sessionId?: string; cwd?: string; navigation?: DshNavigation }) {
|
|
294
415
|
const [snapshot, setSnapshot] = useState(() => service.getSnapshot()); const [panels, setPanels] = useState(() => service.getPanels())
|
|
295
416
|
useEffect(() => service.subscribe(() => { setSnapshot(service.getSnapshot()); setPanels(service.getPanels()) }), [service])
|
|
296
|
-
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-visible')
|
|
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}>()
|
|
297
418
|
const tabsRef=useRef(tabs);tabsRef.current=tabs
|
|
298
|
-
const
|
|
419
|
+
const tabListRef=useRef<HTMLDivElement>(null)
|
|
420
|
+
const workspaceRequests=useRef(new WorkspaceRequestScope());const loadedWorkspaceCwd=useRef<string>();const loadingTabs=useRef(new Map<string, symbol>())
|
|
299
421
|
const dshDark = useDshDarkTheme()
|
|
300
422
|
const [theme,setTheme]=useState<WorkbenchTheme>(()=>{const saved=localStorage.getItem('dvw:theme');return saved==='dark'||saved==='light'?saved:'dsh'})
|
|
301
423
|
const colorScheme: 'dark' | 'light' = theme === 'dsh' ? dshDark ? 'dark' : 'light' : theme
|
|
302
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])
|
|
303
426
|
useEffect(()=>localStorage.setItem('dvw:outline',outlineVisible?'open':'closed'),[outlineVisible])
|
|
304
427
|
useEffect(()=>localStorage.setItem('dvw:dsh-chat-visible',chatVisible?'open':'closed'),[chatVisible])
|
|
305
428
|
useEffect(()=>localStorage.setItem('dvw:dsh-chat-width',String(Math.round(chatWidth))),[chatWidth])
|
|
306
429
|
useEffect(()=>localStorage.setItem('dvw:primary-side-visible',primarySideVisible?'open':'closed'),[primarySideVisible])
|
|
307
|
-
useEffect(()=>localStorage.setItem('dvw:status-visible',statusVisible?'open':'closed'),[statusVisible])
|
|
430
|
+
useEffect(()=>localStorage.setItem('dvw:status-bar-visible',statusVisible?'open':'closed'),[statusVisible])
|
|
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])
|
|
308
433
|
useEffect(() => {
|
|
309
434
|
if (!snapshot.visible) return
|
|
310
435
|
document.body.setAttribute('data-dvw-workbench-active', '')
|
|
311
436
|
return () => document.body.removeAttribute('data-dvw-workbench-active')
|
|
312
437
|
}, [snapshot.visible])
|
|
313
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])
|
|
314
|
-
|
|
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)}
|
|
315
443
|
useEffect(() => {
|
|
316
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
|
|
317
447
|
const epoch = workspaceRequests.current.begin()
|
|
318
448
|
const cleared = resetWorkspaceDocuments<Tab>()
|
|
319
449
|
loadingTabs.current.clear(); setWorkspace(undefined); setTabs(cleared.tabs)
|
|
320
450
|
tabsRef.current = cleared.tabs; setActive(cleared.active); setError(cleared.error)
|
|
321
451
|
const controller = new AbortController()
|
|
322
452
|
const load = async () => {
|
|
323
|
-
|
|
324
|
-
try {
|
|
453
|
+
try {
|
|
325
454
|
const value = await api<{ cwd: string; name: string }>('workspace', { sessionId }, controller.signal)
|
|
326
455
|
if (!workspaceRequests.current.isCurrent(epoch)) return
|
|
327
|
-
setWorkspace(value)
|
|
456
|
+
setWorkspace(value);loadedWorkspaceCwd.current=value.cwd
|
|
328
457
|
let restored: Tab[] = []
|
|
329
458
|
let selected: string | undefined
|
|
330
459
|
try {
|
|
@@ -343,25 +472,45 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
343
472
|
setTabs(restored)
|
|
344
473
|
setActive(restored.some(tab => tab.path === selected) ? selected : restored.at(-1)?.path)
|
|
345
474
|
return
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (delay === undefined) { setError(String(error)); return }
|
|
350
|
-
await new Promise(resolve => setTimeout(resolve, delay))
|
|
351
|
-
if (controller.signal.aborted || !workspaceRequests.current.isCurrent(epoch)) return
|
|
352
|
-
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (controller.signal.aborted || !workspaceRequests.current.isCurrent(epoch)) return
|
|
477
|
+
setError(String(error))
|
|
353
478
|
}
|
|
354
479
|
}
|
|
355
480
|
void load()
|
|
356
481
|
return () => { controller.abort(); loadingTabs.current.clear(); workspaceRequests.current.invalidate(epoch) }
|
|
357
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>()
|
|
358
486
|
useEffect(() => {
|
|
359
487
|
if (!workspace || sessionId === undefined) return
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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)
|
|
364
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])
|
|
365
514
|
const loadTab = useCallback((path: string) => {
|
|
366
515
|
if (sessionId === undefined) return
|
|
367
516
|
const epoch = workspaceRequests.current.current()
|
|
@@ -388,7 +537,8 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
388
537
|
}, [sessionId])
|
|
389
538
|
useEffect(() => {
|
|
390
539
|
if (!snapshot.visible || sessionId === undefined || workspace === undefined) return
|
|
391
|
-
const
|
|
540
|
+
const query = new URLSearchParams({ sessionId })
|
|
541
|
+
const url = websocketUrl(`/dsh-vscode/ws/files?${query.toString()}`)
|
|
392
542
|
if (url === undefined) return
|
|
393
543
|
const ws = new WebSocket(url)
|
|
394
544
|
let closed = false
|
|
@@ -411,7 +561,87 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
411
561
|
const tab = tabsRef.current.find(row => row.path === active)
|
|
412
562
|
if (tab?.loaded === false && !loadingTabs.current.has(active)) loadTab(active)
|
|
413
563
|
}, [active, sessionId, workspace, tabs, loadTab])
|
|
414
|
-
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
const list = tabListRef.current
|
|
566
|
+
const selected = list?.querySelector<HTMLElement>('.dvw-tab[data-active="true"]')
|
|
567
|
+
if (list === null || selected === null || selected === undefined) return
|
|
568
|
+
const listRect = list.getBoundingClientRect()
|
|
569
|
+
const tabRect = selected.getBoundingClientRect()
|
|
570
|
+
list.scrollLeft = tabScrollLeft(list.scrollLeft, listRect, tabRect)
|
|
571
|
+
}, [active, tabs.length])
|
|
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])
|
|
415
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])
|
|
416
646
|
const savingTabs = useRef(new Set<string>())
|
|
417
647
|
const save = async (tab: Tab) => {
|
|
@@ -428,29 +658,63 @@ export function Workbench({ service, sessionId, cwd }: { service: VscodeWorkbenc
|
|
|
428
658
|
finally { savingTabs.current.delete(tab.path) }
|
|
429
659
|
}
|
|
430
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)}
|
|
431
|
-
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)}
|
|
432
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)
|
|
433
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])
|
|
434
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)}
|
|
435
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()}}
|
|
436
678
|
if(!snapshot.visible)return null
|
|
437
|
-
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>
|
|
438
680
|
const markdown=activeTab!==undefined&&!activeTab.path.startsWith('diff:')&&/\.md$/i.test(activeTab.path)
|
|
439
681
|
const diffMarkdown=activeTab?.diff!==undefined&&/\.md$/i.test(activeTab.diffSourcePath??'')
|
|
440
682
|
const markdownPreview=markdown||(diffMarkdown&&activeTab?.preview===true)
|
|
441
683
|
const headings=markdownPreview&&activeTab?markdownHeadings(activeTab.diff?.modified??activeTab.draft):[]
|
|
442
|
-
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}>
|
|
443
685
|
<WorkbenchTitlebar name={workspace?.name}/>
|
|
444
|
-
<DshConversationPanel visible={chatVisible&&!settingsOpen} width={chatWidth} statusHeight={statusVisible?
|
|
445
|
-
<DshConversationTheme visible={chatVisible} colorScheme={colorScheme} followDsh={
|
|
446
|
-
{
|
|
447
|
-
{
|
|
448
|
-
{
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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>
|
|
455
719
|
</div>
|
|
456
720
|
}
|