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.
- package/CHANGELOG.md +15 -0
- package/README.md +11 -9
- package/docs/screenshots/grid-favorites.png +0 -0
- package/docs/screenshots/stl-preview.png +0 -0
- package/docs/screenshots/workspace-search.png +0 -0
- package/lib/client.js +820 -162
- package/package.json +53 -52
- package/scripts/build-client.mjs +1 -0
- package/src/client/conversation-drop.js +135 -0
- package/src/client/main.js +361 -91
- package/src/client/sidebar-motion.js +69 -0
- package/src/document-types.js +2 -2
- package/src/host/ExplorerShell.cs +183 -183
- package/src/host/files.js +25 -6
- package/src/host/pdf-assets.js +30 -30
- package/src/host/shell-files.js +81 -81
- package/src/thumbnail-types.js +18 -18
- package/src/ui/composer-drop.js +119 -119
- package/src/ui/explorer.js +42 -42
- package/src/ui/favorites.js +176 -124
- package/src/ui/file-menu.js +125 -125
- package/src/ui/horizontal-wheel.js +11 -11
- package/src/ui/navigation.js +9 -0
- package/src/ui/pdf-preview.js +135 -135
- package/src/ui/preview-unavailable.js +6 -6
- package/src/ui/preview-zoom.js +47 -47
- package/src/ui/preview.js +58 -12
- package/src/ui/styles.js +74 -15
- package/src/ui/thumbnail.js +105 -105
- package/src/vendor/ui/extra-views.js +1 -1
package/src/host/shell-files.js
CHANGED
|
@@ -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
|
}
|
package/src/thumbnail-types.js
CHANGED
|
@@ -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
|
}
|
package/src/ui/composer-drop.js
CHANGED
|
@@ -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
|
+
}
|
package/src/ui/explorer.js
CHANGED
|
@@ -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 =
|
|
186
|
-
const history =
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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)
|
|
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
|
-
|
|
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' },
|