dsh-workspace-explorer 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,82 +1,82 @@
1
- import { execFile } from 'node:child_process';
2
- import { win32 as path } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { promisify } from 'node:util';
5
-
6
- const execute = promisify(execFile);
7
- const helper = fileURLToPath(new URL('./ExplorerShell.cs', import.meta.url));
8
- const script = String.raw`
9
- $ErrorActionPreference = 'Stop'
10
- [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
11
- [Console]::InputEncoding = [System.Text.Encoding]::UTF8
12
- Add-Type -Path $env:DSH_EXPLORER_SHELL
13
- $request = ConvertFrom-Json ([Console]::In.ReadToEnd())
14
- if ($request.action -in 'stage', 'clipboard') {
15
- Add-Type -AssemblyName System.Windows.Forms
16
- }
17
- switch ($request.action) {
18
- 'stage' {
19
- $paths = New-Object System.Collections.Specialized.StringCollection
20
- foreach ($source in $request.sources) { [void]$paths.Add($source) }
21
- $data = New-Object System.Windows.Forms.DataObject
22
- $data.SetFileDropList($paths)
23
- $effect = if ($request.cut) { 2 } else { 1 }
24
- $stream = New-Object System.IO.MemoryStream -ArgumentList (,[BitConverter]::GetBytes([int]$effect))
25
- $data.SetData('Preferred DropEffect', $false, $stream)
26
- [System.Windows.Forms.Clipboard]::SetDataObject($data, $true, 8, 30)
27
- @{ ok = $true } | ConvertTo-Json -Compress
28
- }
29
- 'clipboard' {
30
- for ($attempt = 0; $attempt -lt 3; $attempt++) {
31
- try {
32
- $sequence = [ExplorerShell]::GetClipboardSequenceNumber()
33
- $paths = @([System.Windows.Forms.Clipboard]::GetFileDropList())
34
- $cut = if ($paths.Count -gt 0) { [ExplorerShell]::ClipboardIsCut() } else { $false }
35
- if ($sequence -eq [ExplorerShell]::GetClipboardSequenceNumber()) {
36
- @{ paths = $paths; cut = $cut; sequence = $sequence } | ConvertTo-Json -Compress
37
- return
38
- }
39
- } catch {
40
- if ($attempt -eq 2) { throw }
41
- }
42
- Start-Sleep -Milliseconds 40
43
- }
44
- throw 'Clipboard changed'
45
- }
46
- 'clearCut' {
47
- [ExplorerShell]::ClearClipboardIfUnchanged($request.sequence)
48
- @{ ok = $true } | ConvertTo-Json -Compress
49
- }
50
- 'transfer' {
51
- $results = @(foreach ($source in $request.sources) {
52
- try { [ExplorerShell]::Transfer($source, $request.destination, $request.cut) }
53
- catch { $false }
54
- })
55
- @{ results = $results } | ConvertTo-Json -Compress
56
- }
57
- 'rename' {
58
- @{ code = [ExplorerShell]::Rename($request.source, $request.destination) } | ConvertTo-Json -Compress
59
- }
60
- 'recycle' {
61
- $results = @(foreach ($source in $request.sources) {
62
- try { [ExplorerShell]::Recycle($source) }
63
- catch { $false }
64
- })
65
- @{ results = $results } | ConvertTo-Json -Compress
66
- }
67
- }
68
- `;
69
- const encoded = Buffer.from(script, 'utf16le').toString('base64');
70
-
71
- export async function windowsFileOperation(request) {
72
- const powershell = path.join(process.env.SystemRoot || 'C:\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
73
- const result = execute(powershell, ['-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', encoded], {
74
- windowsHide: true, maxBuffer: 256 * 1024,
75
- ...(['transfer', 'recycle'].includes(request.action) ? {} : { timeout: 15000 }),
76
- env: { ...process.env, DSH_EXPLORER_SHELL: helper },
77
- });
78
- result.child.stdin.on('error', () => {}); // The process result reports early exit or spawn failure.
79
- result.child.stdin.end(JSON.stringify(request), 'utf8');
80
- const { stdout } = await result;
81
- return JSON.parse(stdout.trim());
1
+ import { execFile } from 'node:child_process';
2
+ import { win32 as path } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execute = promisify(execFile);
7
+ const helper = fileURLToPath(new URL('./ExplorerShell.cs', import.meta.url));
8
+ const script = String.raw`
9
+ $ErrorActionPreference = 'Stop'
10
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
11
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
12
+ Add-Type -Path $env:DSH_EXPLORER_SHELL
13
+ $request = ConvertFrom-Json ([Console]::In.ReadToEnd())
14
+ if ($request.action -in 'stage', 'clipboard') {
15
+ Add-Type -AssemblyName System.Windows.Forms
16
+ }
17
+ switch ($request.action) {
18
+ 'stage' {
19
+ $paths = New-Object System.Collections.Specialized.StringCollection
20
+ foreach ($source in $request.sources) { [void]$paths.Add($source) }
21
+ $data = New-Object System.Windows.Forms.DataObject
22
+ $data.SetFileDropList($paths)
23
+ $effect = if ($request.cut) { 2 } else { 1 }
24
+ $stream = New-Object System.IO.MemoryStream -ArgumentList (,[BitConverter]::GetBytes([int]$effect))
25
+ $data.SetData('Preferred DropEffect', $false, $stream)
26
+ [System.Windows.Forms.Clipboard]::SetDataObject($data, $true, 8, 30)
27
+ @{ ok = $true } | ConvertTo-Json -Compress
28
+ }
29
+ 'clipboard' {
30
+ for ($attempt = 0; $attempt -lt 3; $attempt++) {
31
+ try {
32
+ $sequence = [ExplorerShell]::GetClipboardSequenceNumber()
33
+ $paths = @([System.Windows.Forms.Clipboard]::GetFileDropList())
34
+ $cut = if ($paths.Count -gt 0) { [ExplorerShell]::ClipboardIsCut() } else { $false }
35
+ if ($sequence -eq [ExplorerShell]::GetClipboardSequenceNumber()) {
36
+ @{ paths = $paths; cut = $cut; sequence = $sequence } | ConvertTo-Json -Compress
37
+ return
38
+ }
39
+ } catch {
40
+ if ($attempt -eq 2) { throw }
41
+ }
42
+ Start-Sleep -Milliseconds 40
43
+ }
44
+ throw 'Clipboard changed'
45
+ }
46
+ 'clearCut' {
47
+ [ExplorerShell]::ClearClipboardIfUnchanged($request.sequence)
48
+ @{ ok = $true } | ConvertTo-Json -Compress
49
+ }
50
+ 'transfer' {
51
+ $results = @(foreach ($source in $request.sources) {
52
+ try { [ExplorerShell]::Transfer($source, $request.destination, $request.cut) }
53
+ catch { $false }
54
+ })
55
+ @{ results = $results } | ConvertTo-Json -Compress
56
+ }
57
+ 'rename' {
58
+ @{ code = [ExplorerShell]::Rename($request.source, $request.destination) } | ConvertTo-Json -Compress
59
+ }
60
+ 'recycle' {
61
+ $results = @(foreach ($source in $request.sources) {
62
+ try { [ExplorerShell]::Recycle($source) }
63
+ catch { $false }
64
+ })
65
+ @{ results = $results } | ConvertTo-Json -Compress
66
+ }
67
+ }
68
+ `;
69
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
70
+
71
+ export async function windowsFileOperation(request) {
72
+ const powershell = path.join(process.env.SystemRoot || 'C:\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
73
+ const result = execute(powershell, ['-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', encoded], {
74
+ windowsHide: true, maxBuffer: 256 * 1024,
75
+ ...(['transfer', 'recycle'].includes(request.action) ? {} : { timeout: 15000 }),
76
+ env: { ...process.env, DSH_EXPLORER_SHELL: helper },
77
+ });
78
+ result.child.stdin.on('error', () => {}); // The process result reports early exit or spawn failure.
79
+ result.child.stdin.end(JSON.stringify(request), 'utf8');
80
+ const { stdout } = await result;
81
+ return JSON.parse(stdout.trim());
82
82
  }
@@ -1,19 +1,19 @@
1
- const imageExtensions = new Map([
2
- ['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'],
3
- ['webp', 'image/webp'], ['gif', 'image/gif'], ['bmp', 'image/bmp'],
4
- ['ico', 'image/x-icon'], ['avif', 'image/avif'], ['apng', 'image/apng'],
5
- ['jfif', 'image/jpeg'], ['heic', 'image/heic'], ['heif', 'image/heif'],
6
- ['tif', 'image/tiff'], ['tiff', 'image/tiff'],
7
- ]);
8
-
9
- const videoExtensions = new Map([
10
- ['mp4', 'video/mp4'], ['m4v', 'video/mp4'], ['webm', 'video/webm'],
11
- ['mov', 'video/quicktime'], ['mkv', 'video/x-matroska'], ['ogv', 'video/ogg'],
12
- ['avi', 'video/x-msvideo'], ['wmv', 'video/x-ms-wmv'], ['mpeg', 'video/mpeg'],
13
- ['mpg', 'video/mpeg'], ['3gp', 'video/3gpp'],
14
- ]);
15
-
16
- export function thumbnailType(path) {
17
- const extension = /\.([a-z0-9]+)$/i.exec(path)?.[1].toLowerCase();
18
- return imageExtensions.get(extension) ?? videoExtensions.get(extension);
1
+ const imageExtensions = new Map([
2
+ ['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'],
3
+ ['webp', 'image/webp'], ['gif', 'image/gif'], ['bmp', 'image/bmp'],
4
+ ['ico', 'image/x-icon'], ['avif', 'image/avif'], ['apng', 'image/apng'],
5
+ ['jfif', 'image/jpeg'], ['heic', 'image/heic'], ['heif', 'image/heif'],
6
+ ['tif', 'image/tiff'], ['tiff', 'image/tiff'],
7
+ ]);
8
+
9
+ const videoExtensions = new Map([
10
+ ['mp4', 'video/mp4'], ['m4v', 'video/mp4'], ['webm', 'video/webm'],
11
+ ['mov', 'video/quicktime'], ['mkv', 'video/x-matroska'], ['ogv', 'video/ogg'],
12
+ ['avi', 'video/x-msvideo'], ['wmv', 'video/x-ms-wmv'], ['mpeg', 'video/mpeg'],
13
+ ['mpg', 'video/mpeg'], ['3gp', 'video/3gpp'],
14
+ ]);
15
+
16
+ export function thumbnailType(path) {
17
+ const extension = /\.([a-z0-9]+)$/i.exec(path)?.[1].toLowerCase();
18
+ return imageExtensions.get(extension) ?? videoExtensions.get(extension);
19
19
  }
@@ -1,119 +1,119 @@
1
- import { disks } from './workspace.js';
2
-
3
- const { useEffect, useRef } = react;
4
- const dragType = 'application/x-dsh-workspace-explorer-file';
5
- const imageTypes = new Map([
6
- ['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'],
7
- ['gif', 'image/gif'], ['webp', 'image/webp'],
8
- ]);
9
-
10
- export function useComposerDrop(sessionId, openFolderTab, reportError, root) {
11
- const active = useRef(null);
12
-
13
- useEffect(() => {
14
- if (!sessionId) return;
15
- const lifetime = new AbortController();
16
- const signal = lifetime.signal;
17
- let highlighted;
18
- let highlightClass;
19
-
20
- function highlight(element, className) {
21
- if (element === highlighted && className === highlightClass) return;
22
- if (highlighted) highlighted.classList.remove(highlightClass);
23
- highlighted = element;
24
- highlightClass = className;
25
- if (highlighted) highlighted.classList.add(highlightClass);
26
- }
27
-
28
- function targetCard(target) {
29
- if (!(target instanceof Element)) return null;
30
- const conversation = target.closest('[data-conversation-content]');
31
- if (conversation?.dataset.conversationSession !== sessionId) return null;
32
- const card = conversation.querySelector('[data-composer-card]');
33
- return card?.querySelector('[data-composer-input][contenteditable="true"]') ? card : null;
34
- }
35
-
36
- function over(event) {
37
- const drag = active.current;
38
- if (!drag || !event.dataTransfer?.types.includes(dragType)) return;
39
- const strip = drag.folder && tabStrip(event.target);
40
- if (strip) {
41
- highlight(strip, 'dwe-tab-drop-target');
42
- event.preventDefault();
43
- event.dataTransfer.dropEffect = 'copy';
44
- return;
45
- }
46
- const card = targetCard(event.target);
47
- highlight(card, 'dwe-drop-target');
48
- if (!card) return;
49
- event.preventDefault();
50
- event.dataTransfer.dropEffect = 'copy';
51
- }
52
-
53
- function reset() {
54
- active.current = null;
55
- highlight(null);
56
- }
57
-
58
- async function drop(event) {
59
- const file = active.current;
60
- if (!file || event.dataTransfer?.getData(dragType) !== file.token) return;
61
- const strip = file.folder && tabStrip(event.target);
62
- const card = targetCard(event.target);
63
- reset();
64
- if (!strip && !card) return;
65
- event.preventDefault();
66
- event.stopPropagation();
67
- reportError('');
68
- try {
69
- if (strip) {
70
- openFolderTab(file.path, strip.dataset.dockkitStrip);
71
- return;
72
- }
73
- if (file.folder) {
74
- const editor = card.querySelector('[data-composer-input][contenteditable="true"]');
75
- const data = new DataTransfer();
76
- data.setData('text/plain', file.path);
77
- editor.focus({ preventScroll: true });
78
- editor.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }));
79
- return;
80
- }
81
- const bytes = await disks.readBytes(file.path, signal, root);
82
- // The composer may have switched sessions or become locked during the read.
83
- if (signal.aborted || !card.isConnected || targetCard(card) !== card) return;
84
- const extension = file.name.split('.').at(-1)?.toLowerCase();
85
- const dataTransfer = new DataTransfer();
86
- dataTransfer.items.add(new File([bytes], file.name, {
87
- type: imageTypes.get(extension) ?? 'application/octet-stream',
88
- }));
89
- // Use the composer's existing attachment intake, including its file checks.
90
- card.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }));
91
- } catch (error) {
92
- if (!signal.aborted) reportError('无法拖入会话 · ' + (error?.message || String(error)));
93
- }
94
- }
95
-
96
- document.addEventListener('dragover', over, { signal, capture: true });
97
- document.addEventListener('drop', drop, { signal, capture: true });
98
- window.addEventListener('dragend', reset, { signal });
99
- return () => {
100
- lifetime.abort();
101
- reset();
102
- };
103
- }, [sessionId, openFolderTab, reportError, root]);
104
-
105
- return (event, path, name, folder = false) => {
106
- if (!sessionId) {
107
- event.preventDefault();
108
- return;
109
- }
110
- const token = crypto.randomUUID();
111
- active.current = { token, path, name, folder };
112
- event.dataTransfer.effectAllowed = 'copy';
113
- event.dataTransfer.setData(dragType, token);
114
- };
115
- }
116
-
117
- function tabStrip(target) {
118
- return target instanceof Element ? target.closest('[data-dockkit-strip]') : null;
119
- }
1
+ import { disks } from './workspace.js';
2
+
3
+ const { useEffect, useRef } = react;
4
+ const dragType = 'application/x-dsh-workspace-explorer-file';
5
+ const imageTypes = new Map([
6
+ ['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'],
7
+ ['gif', 'image/gif'], ['webp', 'image/webp'],
8
+ ]);
9
+
10
+ export function useComposerDrop(sessionId, openFolderTab, reportError, root) {
11
+ const active = useRef(null);
12
+
13
+ useEffect(() => {
14
+ if (!sessionId) return;
15
+ const lifetime = new AbortController();
16
+ const signal = lifetime.signal;
17
+ let highlighted;
18
+ let highlightClass;
19
+
20
+ function highlight(element, className) {
21
+ if (element === highlighted && className === highlightClass) return;
22
+ if (highlighted) highlighted.classList.remove(highlightClass);
23
+ highlighted = element;
24
+ highlightClass = className;
25
+ if (highlighted) highlighted.classList.add(highlightClass);
26
+ }
27
+
28
+ function targetCard(target) {
29
+ if (!(target instanceof Element)) return null;
30
+ const conversation = target.closest('[data-conversation-content]');
31
+ if (conversation?.dataset.conversationSession !== sessionId) return null;
32
+ const card = conversation.querySelector('[data-composer-card]');
33
+ return card?.querySelector('[data-composer-input][contenteditable="true"]') ? card : null;
34
+ }
35
+
36
+ function over(event) {
37
+ const drag = active.current;
38
+ if (!drag || !event.dataTransfer?.types.includes(dragType)) return;
39
+ const strip = drag.folder && tabStrip(event.target);
40
+ if (strip) {
41
+ highlight(strip, 'dwe-tab-drop-target');
42
+ event.preventDefault();
43
+ event.dataTransfer.dropEffect = 'copy';
44
+ return;
45
+ }
46
+ const card = targetCard(event.target);
47
+ highlight(card, 'dwe-drop-target');
48
+ if (!card) return;
49
+ event.preventDefault();
50
+ event.dataTransfer.dropEffect = 'copy';
51
+ }
52
+
53
+ function reset() {
54
+ active.current = null;
55
+ highlight(null);
56
+ }
57
+
58
+ async function drop(event) {
59
+ const file = active.current;
60
+ if (!file || event.dataTransfer?.getData(dragType) !== file.token) return;
61
+ const strip = file.folder && tabStrip(event.target);
62
+ const card = targetCard(event.target);
63
+ reset();
64
+ if (!strip && !card) return;
65
+ event.preventDefault();
66
+ event.stopPropagation();
67
+ reportError('');
68
+ try {
69
+ if (strip) {
70
+ openFolderTab(file.path, strip.dataset.dockkitStrip);
71
+ return;
72
+ }
73
+ if (file.folder) {
74
+ const editor = card.querySelector('[data-composer-input][contenteditable="true"]');
75
+ const data = new DataTransfer();
76
+ data.setData('text/plain', file.path);
77
+ editor.focus({ preventScroll: true });
78
+ editor.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }));
79
+ return;
80
+ }
81
+ const bytes = await disks.readBytes(file.path, signal, root);
82
+ // The composer may have switched sessions or become locked during the read.
83
+ if (signal.aborted || !card.isConnected || targetCard(card) !== card) return;
84
+ const extension = file.name.split('.').at(-1)?.toLowerCase();
85
+ const dataTransfer = new DataTransfer();
86
+ dataTransfer.items.add(new File([bytes], file.name, {
87
+ type: imageTypes.get(extension) ?? 'application/octet-stream',
88
+ }));
89
+ // Use the composer's existing attachment intake, including its file checks.
90
+ card.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }));
91
+ } catch (error) {
92
+ if (!signal.aborted) reportError('无法拖入会话 · ' + (error?.message || String(error)));
93
+ }
94
+ }
95
+
96
+ document.addEventListener('dragover', over, { signal, capture: true });
97
+ document.addEventListener('drop', drop, { signal, capture: true });
98
+ window.addEventListener('dragend', reset, { signal });
99
+ return () => {
100
+ lifetime.abort();
101
+ reset();
102
+ };
103
+ }, [sessionId, openFolderTab, reportError, root]);
104
+
105
+ return (event, path, name, folder = false) => {
106
+ if (!sessionId) {
107
+ event.preventDefault();
108
+ return;
109
+ }
110
+ const token = crypto.randomUUID();
111
+ active.current = { token, path, name, folder };
112
+ event.dataTransfer.effectAllowed = 'copy';
113
+ event.dataTransfer.setData(dragType, token);
114
+ };
115
+ }
116
+
117
+ function tabStrip(target) {
118
+ return target instanceof Element ? target.closest('[data-dockkit-strip]') : null;
119
+ }
@@ -1,5 +1,5 @@
1
1
  import { joinPath, move, parentOf, renamePath, resourceAddress, underPath, visit, windowsPath } from './navigation.js';
2
- import { Preview, PreviewBoundary } from './preview.js';
2
+ import { formatSize, Preview, PreviewBoundary } from './preview.js';
3
3
  import { disks } from './workspace.js';
4
4
  import { useComposerDrop } from './composer-drop.js';
5
5
  import { Thumbnail } from './thumbnail.js';
@@ -80,14 +80,6 @@ function selectNames(current, name, entries, shift, ctrl) {
80
80
  return { names, anchor: name };
81
81
  }
82
82
 
83
- function formatSize(bytes) {
84
- if (typeof bytes !== 'number') return '—';
85
- if (bytes < 1024) return bytes + ' B';
86
- if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB';
87
- if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB';
88
- return (bytes / 1024 ** 3).toFixed(1) + ' GB';
89
- }
90
-
91
83
  function entryType(entry) {
92
84
  if (entry.type === 'drive') return '磁盘';
93
85
  if (entry.type === 'directory') return '文件夹';
@@ -175,21 +167,19 @@ function PreviewDivider({ root, ratio, setRatio }) {
175
167
  }
176
168
 
177
169
  export function Explorer({
178
- cwd, sessionId, initialPath, bindFileActions, getTarget, onPath, openLocal, openFolderTab,
170
+ cwd, sessionId, initialPath, bindFileActions, getTarget, onPath, openLocal, openDocument, openFolderTab,
179
171
  refreshDirectories, updateRenamedPath,
180
172
  }) {
181
173
  const workspace = windowsPath(cwd);
182
174
  const [localHistory, setLocalHistory] = useState(() => firstPath(initialPath ?? workspace));
183
175
  const [scoped, setScoped] = useState(null);
184
176
  const workspaceMode = scoped !== null;
185
- const root = workspaceMode ? (sessionId ? workspace : scoped.root) : null;
186
- const history = workspaceMode ? (root === scoped.root ? scoped.history : firstPath(root)) : localHistory;
177
+ const root = scoped?.root ?? null;
178
+ const history = scoped?.history ?? localHistory;
187
179
  const changeHistory = useCallback(change => {
188
- if (workspaceMode) setScoped(current => current && {
189
- root, history: change(current.root === root ? current.history : firstPath(root)),
190
- });
180
+ if (workspaceMode) setScoped(current => current && { ...current, history: change(current.history) });
191
181
  else setLocalHistory(change);
192
- }, [workspaceMode, root]);
182
+ }, [workspaceMode]);
193
183
  const [revision, setRevision] = useState(0);
194
184
  const [listing, setListing] = useState(null);
195
185
  const [selection, setSelection] = useState(emptySelection);
@@ -244,7 +234,7 @@ export function Explorer({
244
234
  if (!current) return current;
245
235
  const absolute = renamePath(current.absolute, from, to);
246
236
  return absolute === current.absolute ? current
247
- : { absolute, name: absolute.split('\\').at(-1), address: resourceAddress(absolute) };
237
+ : { ...current, absolute, name: absolute.split('\\').at(-1), address: resourceAddress(absolute) };
248
238
  });
249
239
  }, []);
250
240
  const closeMenu = useCallback((restore = false) => {
@@ -295,12 +285,6 @@ export function Explorer({
295
285
  setScoped(workspaceMode ? null : { root: workspace, history: firstPath(workspace) });
296
286
  }
297
287
 
298
- useLayoutEffect(() => {
299
- if (!workspaceMode || !root || root === scoped.root) return;
300
- resetNavigation();
301
- setScoped({ root, history: firstPath(root) });
302
- }, [workspaceMode, root, scoped?.root, resetNavigation]);
303
-
304
288
  useLayoutEffect(() => { onPath(path); }, [onPath, path]);
305
289
 
306
290
  useEffect(() => {
@@ -331,7 +315,6 @@ export function Explorer({
331
315
  }, [root, term, revision, sessionId]);
332
316
 
333
317
  useEffect(() => () => pathRequest.current?.abort(), []);
334
- useLayoutEffect(() => bindFileActions({ refresh, rename }), [bindFileActions, refresh, rename]);
335
318
  useLayoutEffect(() => { scroll.current.scrollTop = 0; }, [path, term]);
336
319
  useLayoutEffect(() => {
337
320
  const result = resultToReveal.current;
@@ -356,20 +339,33 @@ export function Explorer({
356
339
  changeHistory(value => move(value, delta));
357
340
  }
358
341
 
359
- function openPreview(absolute, name) {
360
- setPreview(current => current?.absolute === absolute ? current : {
361
- name, absolute, address: resourceAddress(absolute),
342
+ const openPreview = useCallback((absolute, name, size, line) => {
343
+ const targetLine = Number.isSafeInteger(line) && line > 0 ? line : undefined;
344
+ setPreview(current => current?.absolute === absolute && !targetLine ? current : {
345
+ name, absolute, size, line: targetLine, address: resourceAddress(absolute),
362
346
  });
363
- }
347
+ }, []);
364
348
 
365
- function openSearchHit(match) {
366
- if (!underPath(match.path, root)) return;
367
- const folder = parentOf(match.path);
368
- navigate(folder);
369
- resultToReveal.current = { folder, name: match.name };
370
- setSelection(singleSelection(match.name));
371
- openPreview(match.path, match.name);
349
+ const revealFile = useCallback((absolute, name, size, line) => {
350
+ const folder = parentOf(absolute);
351
+ if (workspaceMode && !underPath(folder, root)) {
352
+ resetNavigation();
353
+ setScoped(null);
354
+ setLocalHistory(history => visit(history, folder));
355
+ } else {
356
+ navigate(folder);
357
+ if (folder === path && ready && !ready.entries.some(entry => entry.name === name)) refresh();
358
+ }
359
+ resultToReveal.current = { folder, name };
360
+ setSelection(singleSelection(name));
361
+ openPreview(absolute, name, size, line);
372
362
  setPreviewOpen(true);
363
+ }, [workspaceMode, root, path, ready, resetNavigation, navigate, refresh, openPreview]);
364
+
365
+ useLayoutEffect(() => bindFileActions({ refresh, rename, reveal: revealFile }), [bindFileActions, refresh, rename, revealFile]);
366
+
367
+ function openSearchHit(match) {
368
+ if (underPath(match.path, root)) revealFile(match.path, match.name);
373
369
  }
374
370
 
375
371
  function select(event, entry) {
@@ -378,7 +374,7 @@ export function Explorer({
378
374
  setSelection(current => selectNames(current, entry.name, entries, shiftKey, ctrlKey));
379
375
  setActionError('');
380
376
  setActionNotice('');
381
- if (!shiftKey && !ctrlKey && entry.type === 'file') openPreview(joinPath(path, entry.name), entry.name);
377
+ if (!shiftKey && !ctrlKey && entry.type === 'file') openPreview(joinPath(path, entry.name), entry.name, entry.size);
382
378
  }
383
379
 
384
380
  async function openWithWindows(path) {
@@ -511,7 +507,7 @@ export function Explorer({
511
507
  if (target.type === 'directory') navigate(windowsPath(target.path));
512
508
  else {
513
509
  closeEditor();
514
- openPreview(target.path, target.name);
510
+ openPreview(target.path, target.name, target.size);
515
511
  }
516
512
  scroll.current.focus();
517
513
  } catch (error) {
@@ -529,7 +525,9 @@ export function Explorer({
529
525
  if (!target || !shownPreview) return;
530
526
  try {
531
527
  if (root) await disks.locate(shownPreview.absolute, undefined, root);
532
- if (getTarget() === target) target.actions.openResource(resourceAddress(shownPreview.absolute, target.sessionId));
528
+ if (getTarget() === target) openDocument(resourceAddress(shownPreview.absolute, target.sessionId), {
529
+ paneId: target.paneId, params: shownPreview.line ? { line: shownPreview.line } : undefined,
530
+ });
533
531
  } catch (error) {
534
532
  setActionError(error?.message || String(error));
535
533
  }
@@ -586,6 +584,7 @@ export function Explorer({
586
584
  tool('home', workspaceMode ? '返回工作区根目录' : '返回此电脑', () => navigate(root), path === root),
587
585
  tool('workspace', '跳转至工作区', () => navigate(workspace), !workspace || path === workspace || workspaceMode)),
588
586
  h('div', { className: 'dwe-nav' },
587
+ h('span', { className: 'dwe-v-divider', 'aria-hidden': true }),
589
588
  tool('copy', '复制', () => runAction('copy', selectedEntries), !modifySelected),
590
589
  tool('cut', '剪切', () => runAction('cut', selectedEntries), !modifySelected),
591
590
  tool('paste', '粘贴', () => runAction('paste'), !path || working),
@@ -651,14 +650,15 @@ export function Explorer({
651
650
  !grid && h('span', { className: 'dwe-meta' }, entryType(entry)),
652
651
  !grid && h('span', { className: 'dwe-meta' }, formatSize(entry.size))))))),
653
652
  h('footer', { className: 'dwe-status-bar' },
653
+ h('div', { className: 'dwe-nav', role: 'group', 'aria-label': '文件视图' },
654
+ tool('list', '列表', () => changeView('list'), false, !grid),
655
+ tool('grid', '大图标', () => changeView('grid'), false, grid)),
656
+ h('span', { className: 'dwe-v-divider', 'aria-hidden': true }),
654
657
  h('span', { className: 'dwe-status', role: 'status', title: actionNotice },
655
658
  term ? found?.matches ? found.matches.length + ' 个结果' + (found.truncated ? ' · 请缩小关键词' : '') : ' '
656
659
  : actionNotice || (selectedEntries.length > 1 ? '已选择 ' + selectedEntries.length + ' 项'
657
660
  : ready?.entries ? ready.entries.length + ' 个项目' + (ready.truncated ? ' · 列表已截断' : '') : ' ')),
658
- h('div', { className: 'dwe-nav', role: 'group', 'aria-label': '视图与预览' },
659
- tool('list', '列表', () => changeView('list'), false, !grid),
660
- tool('grid', '大图标', () => changeView('grid'), false, grid),
661
- tool('preview', '预览窗格', () => setPreviewOpen(open => !open), false, previewOpen)))),
661
+ tool('preview', '预览窗格', () => setPreviewOpen(open => !open), false, previewOpen))),
662
662
  previewOpen && h(PreviewDivider, { root: layout, ratio: splitRatio, setRatio: setSplitRatio }),
663
663
  previewOpen && h('section', { className: 'dwe-preview', 'aria-label': '文件预览' },
664
664
  h('header', { className: 'dwe-preview-bar' },