@lijian-ui/dsh-file-manager 0.2.2 → 0.2.4
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/LICENSE +38 -38
- package/README.i18n.yaml +6 -6
- package/README.md +66 -66
- package/README.zh.md +66 -66
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27354 -355
- package/lib/client.js.map +1 -1
- package/lib/index.js +474 -42
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/file-source.d.ts +10 -0
- package/lib/types/client/file-source.d.ts.map +1 -1
- package/lib/types/client/fileType.d.ts +19 -4
- package/lib/types/client/fileType.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +0 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/mount.d.ts +1 -1
- package/lib/types/client/mount.d.ts.map +1 -1
- package/lib/types/client/picker/FilePickerModal.d.ts +8 -6
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
- package/lib/types/client/preview/content.d.ts +3 -1
- package/lib/types/client/preview/content.d.ts.map +1 -1
- package/lib/types/client/preview/office.d.ts +23 -0
- package/lib/types/client/preview/office.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +23 -10
- package/lib/types/client/reference.d.ts.map +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/host/fs-service.d.ts.map +1 -1
- package/lib/types/host/office-preview.d.ts +13 -0
- package/lib/types/host/office-preview.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -4
- package/src/client/DockItem.tsx +1 -1
- package/src/client/FileManagerSettingsCard.tsx +117 -117
- package/src/client/PluginSettingsCard.tsx +337 -337
- package/src/client/chat/file-ref.ts +144 -144
- package/src/client/chat/mermaid-chat.tsx +102 -102
- package/src/client/chat/placeholder-hint.tsx +46 -46
- package/src/client/components/ExplorerPanel.tsx +534 -534
- package/src/client/components/FileIcon.tsx +46 -46
- package/src/client/components/ScmPanel.tsx +480 -480
- package/src/client/components/a11y.ts +20 -20
- package/src/client/components/icons.tsx +274 -274
- package/src/client/components/overlay.tsx +225 -225
- package/src/client/drag/DragFileInlay.tsx +89 -89
- package/src/client/drag/file-drag.ts +67 -67
- package/src/client/drag.ts +154 -154
- package/src/client/file-source.ts +34 -0
- package/src/client/fileType.ts +32 -5
- package/src/client/hooks/useResizableSplit.ts +91 -91
- package/src/client/hooks/useStore.ts +15 -15
- package/src/client/index.ts +45 -17
- package/src/client/layout.ts +523 -523
- package/src/client/locales.ts +6 -0
- package/src/client/maximize.ts +34 -34
- package/src/client/mount.tsx +2 -1
- package/src/client/persist.ts +194 -194
- package/src/client/picker/FilePickerModal.tsx +526 -507
- package/src/client/picker/file-picker.ts +86 -86
- package/src/client/preview/PreviewPanel.tsx +8 -1
- package/src/client/preview/PreviewTabs.tsx +159 -159
- package/src/client/preview/PreviewToolbar.tsx +3 -2
- package/src/client/preview/content.tsx +7 -2
- package/src/client/preview/markdown.ts +351 -351
- package/src/client/preview/mermaid.ts +274 -274
- package/src/client/preview/office.tsx +321 -0
- package/src/client/reference.ts +62 -8
- package/src/client/settings-card.module.css +316 -316
- package/src/client/settings-form.ts +451 -451
- package/src/client/store.ts +17 -12
- package/src/client/styles/drag.module.css +30 -30
- package/src/client/styles/explorer.module.css +371 -371
- package/src/client/styles/picker.module.css +344 -344
- package/src/client/styles/preview.module.css +18 -11
- package/src/client/styles/scm.module.css +380 -380
- package/src/client/styles/tokens.module.css +376 -367
- package/src/core/types.ts +162 -162
- package/src/host/fs-service.ts +7 -1
- package/src/host/gate.ts +73 -73
- package/src/host/git-runner.ts +116 -116
- package/src/host/git-service.ts +395 -395
- package/src/host/loopback.ts +63 -63
- package/src/host/office-preview.ts +399 -0
- package/src/host/poll-guard.ts +109 -109
- package/src/host/routes.ts +72 -1
- package/src/index.ts +3 -1
- package/src/mount-once.ts +48 -48
- package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
- package/lib/types/client/floating.d.ts +0 -27
- package/lib/types/client/floating.d.ts.map +0 -1
- package/lib/types/client/mention.d.ts +0 -44
- package/lib/types/client/mention.d.ts.map +0 -1
- package/src/client/styles/chip.module.css +0 -38
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File-picker bridge: the single channel between the '@' trigger source
|
|
3
|
-
* (which asks for a directory) and the tree-browser modal (which renders it).
|
|
4
|
-
*
|
|
5
|
-
* Why a modal and not the inline trigger menu: the shell's trigger menu is a
|
|
6
|
-
* flat candidate list with no tree affordance — picking a directory cannot
|
|
7
|
-
* expand it (the menu closes on pick, and the shell only re-runs trigger
|
|
8
|
-
* detection on keyboard input, not on programmatic draft writes). The modal
|
|
9
|
-
* is the pi-desktop-style answer: a real directory tree with lazy expansion,
|
|
10
|
-
* a search box, and one-click insertion.
|
|
11
|
-
*
|
|
12
|
-
* The bridge is framework-free (subscribe/getSnapshot/open/close) so the
|
|
13
|
-
* trigger source can open the picker without touching React; the modal host
|
|
14
|
-
* component subscribes and renders. Insertion happens through the
|
|
15
|
-
* conversation input shell, replacing the exact `@` token span the picker
|
|
16
|
-
* was opened from.
|
|
17
|
-
* @module dsh-filemgr/client/picker/file-picker
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
21
|
-
|
|
22
|
-
/** The `@` token span captured when the picker opened (replace-on-insert). */
|
|
23
|
-
export interface PickSpan {
|
|
24
|
-
/** Start offset of the `@` token in the draft. */
|
|
25
|
-
start: number
|
|
26
|
-
/** End offset (the caret). */
|
|
27
|
-
end: number
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** What the picker is currently anchored to. */
|
|
31
|
-
export interface PickRequest {
|
|
32
|
-
/** Session whose draft the picker will edit. */
|
|
33
|
-
sessionId: SessionId
|
|
34
|
-
/** Project root (canonical cwd) everything is relative to. */
|
|
35
|
-
root: string
|
|
36
|
-
/** Directory to start browsing at ('' = root). */
|
|
37
|
-
initialDir: string
|
|
38
|
-
/** The `@` token span to replace on insert. */
|
|
39
|
-
span: PickSpan
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** The bridge's public snapshot. */
|
|
43
|
-
export interface PickState {
|
|
44
|
-
open: boolean
|
|
45
|
-
req: PickRequest | null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Minimal listener shape. */
|
|
49
|
-
type Listener = (state: PickState) => void
|
|
50
|
-
|
|
51
|
-
/** Framework-free singleton store backing the modal. */
|
|
52
|
-
class FilePickerBridge {
|
|
53
|
-
private state: PickState = { open: false, req: null }
|
|
54
|
-
private readonly listeners = new Set<Listener>()
|
|
55
|
-
|
|
56
|
-
/** Current snapshot (stable reference until the next open/close). */
|
|
57
|
-
getSnapshot(): PickState {
|
|
58
|
-
return this.state
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Subscribe to open/close transitions. */
|
|
62
|
-
subscribe(listener: Listener): () => void {
|
|
63
|
-
this.listeners.add(listener)
|
|
64
|
-
return () => { this.listeners.delete(listener) }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Open the picker anchored to one `@` token. */
|
|
68
|
-
open(req: PickRequest): void {
|
|
69
|
-
this.state = { open: true, req }
|
|
70
|
-
this.emit()
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Close the picker without inserting anything. */
|
|
74
|
-
close(): void {
|
|
75
|
-
if (!this.state.open) return
|
|
76
|
-
this.state = { open: false, req: null }
|
|
77
|
-
this.emit()
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
private emit(): void {
|
|
81
|
-
for (const listener of this.listeners) listener(this.state)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** The shared singleton instance. */
|
|
86
|
-
export const filePicker = new FilePickerBridge()
|
|
1
|
+
/**
|
|
2
|
+
* File-picker bridge: the single channel between the '@' trigger source
|
|
3
|
+
* (which asks for a directory) and the tree-browser modal (which renders it).
|
|
4
|
+
*
|
|
5
|
+
* Why a modal and not the inline trigger menu: the shell's trigger menu is a
|
|
6
|
+
* flat candidate list with no tree affordance — picking a directory cannot
|
|
7
|
+
* expand it (the menu closes on pick, and the shell only re-runs trigger
|
|
8
|
+
* detection on keyboard input, not on programmatic draft writes). The modal
|
|
9
|
+
* is the pi-desktop-style answer: a real directory tree with lazy expansion,
|
|
10
|
+
* a search box, and one-click insertion.
|
|
11
|
+
*
|
|
12
|
+
* The bridge is framework-free (subscribe/getSnapshot/open/close) so the
|
|
13
|
+
* trigger source can open the picker without touching React; the modal host
|
|
14
|
+
* component subscribes and renders. Insertion happens through the
|
|
15
|
+
* conversation input shell, replacing the exact `@` token span the picker
|
|
16
|
+
* was opened from.
|
|
17
|
+
* @module dsh-filemgr/client/picker/file-picker
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
21
|
+
|
|
22
|
+
/** The `@` token span captured when the picker opened (replace-on-insert). */
|
|
23
|
+
export interface PickSpan {
|
|
24
|
+
/** Start offset of the `@` token in the draft. */
|
|
25
|
+
start: number
|
|
26
|
+
/** End offset (the caret). */
|
|
27
|
+
end: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the picker is currently anchored to. */
|
|
31
|
+
export interface PickRequest {
|
|
32
|
+
/** Session whose draft the picker will edit. */
|
|
33
|
+
sessionId: SessionId
|
|
34
|
+
/** Project root (canonical cwd) everything is relative to. */
|
|
35
|
+
root: string
|
|
36
|
+
/** Directory to start browsing at ('' = root). */
|
|
37
|
+
initialDir: string
|
|
38
|
+
/** The `@` token span to replace on insert. */
|
|
39
|
+
span: PickSpan
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The bridge's public snapshot. */
|
|
43
|
+
export interface PickState {
|
|
44
|
+
open: boolean
|
|
45
|
+
req: PickRequest | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimal listener shape. */
|
|
49
|
+
type Listener = (state: PickState) => void
|
|
50
|
+
|
|
51
|
+
/** Framework-free singleton store backing the modal. */
|
|
52
|
+
class FilePickerBridge {
|
|
53
|
+
private state: PickState = { open: false, req: null }
|
|
54
|
+
private readonly listeners = new Set<Listener>()
|
|
55
|
+
|
|
56
|
+
/** Current snapshot (stable reference until the next open/close). */
|
|
57
|
+
getSnapshot(): PickState {
|
|
58
|
+
return this.state
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Subscribe to open/close transitions. */
|
|
62
|
+
subscribe(listener: Listener): () => void {
|
|
63
|
+
this.listeners.add(listener)
|
|
64
|
+
return () => { this.listeners.delete(listener) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Open the picker anchored to one `@` token. */
|
|
68
|
+
open(req: PickRequest): void {
|
|
69
|
+
this.state = { open: true, req }
|
|
70
|
+
this.emit()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Close the picker without inserting anything. */
|
|
74
|
+
close(): void {
|
|
75
|
+
if (!this.state.open) return
|
|
76
|
+
this.state = { open: false, req: null }
|
|
77
|
+
this.emit()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private emit(): void {
|
|
81
|
+
for (const listener of this.listeners) listener(this.state)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The shared singleton instance. */
|
|
86
|
+
export const filePicker = new FilePickerBridge()
|
|
@@ -28,7 +28,11 @@ interface AddToChatState {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/** The preview panel (mounted in the preview grid column). */
|
|
31
|
-
export function PreviewPanel({ stores, onAddFileReference
|
|
31
|
+
export function PreviewPanel({ stores, onAddFileReference, onInsertSheetReference }: {
|
|
32
|
+
stores: PanelStores
|
|
33
|
+
onAddFileReference: (selection: FileReferenceSelection) => boolean
|
|
34
|
+
onInsertSheetReference: (path: string, sheetName: string, range: string) => boolean
|
|
35
|
+
}): JSX.Element {
|
|
32
36
|
const preview = stores.preview
|
|
33
37
|
const state = useStore(preview)
|
|
34
38
|
const layoutState = useStore(stores.layout)
|
|
@@ -70,6 +74,7 @@ export function PreviewPanel({ stores, onAddFileReference }: { stores: PanelStor
|
|
|
70
74
|
}
|
|
71
75
|
const ref = findFileReferenceSelection(el, selection, activeTab)
|
|
72
76
|
if (ref === null) {
|
|
77
|
+
|
|
73
78
|
setAddToChat(null)
|
|
74
79
|
return
|
|
75
80
|
}
|
|
@@ -188,6 +193,7 @@ export function PreviewPanel({ stores, onAddFileReference }: { stores: PanelStor
|
|
|
188
193
|
canToggleView={activeTab.contentType === 'markdown' || activeTab.contentType === 'html'}
|
|
189
194
|
split={split}
|
|
190
195
|
canSplit={isEditableType(activeTab.contentType) && activeTab.content !== null}
|
|
196
|
+
editable={isEditableType(activeTab.contentType)}
|
|
191
197
|
onViewModeChange={setViewMode}
|
|
192
198
|
onSplitChange={setSplit}
|
|
193
199
|
onRefresh={() => void preview.reloadTab(activeTab.id)}
|
|
@@ -198,6 +204,7 @@ export function PreviewPanel({ stores, onAddFileReference }: { stores: PanelStor
|
|
|
198
204
|
tab={activeTab}
|
|
199
205
|
viewMode={viewMode}
|
|
200
206
|
split={split}
|
|
207
|
+
onInsertSheetReference={onInsertSheetReference}
|
|
201
208
|
onContentChange={(content) => preview.updateContent(activeTab.id, content)}
|
|
202
209
|
onSave={() => void preview.saveTab(activeTab.id)}
|
|
203
210
|
/>
|
|
@@ -1,159 +1,159 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The preview tab strip: 36px bar, tabs capped at 180px (padding 0 10, gap 6,
|
|
3
|
-
* 12px title), dirty dot (6px, primary), close glyph (16px box, 12px icon),
|
|
4
|
-
* middle-click close, right-click menu (close left/right/others/all), the
|
|
5
|
-
* 32px left/right overflow fade indicators (ResizeObserver + scroll), the
|
|
6
|
-
* new-URL-tab plus, and the panel collapse button.
|
|
7
|
-
* @module dsh-filemgr/client/preview/PreviewTabs
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { useEffect, useRef, useState } from 'react'
|
|
11
|
-
import type { JSX } from 'react'
|
|
12
|
-
import type { PreviewTabState } from '../store.ts'
|
|
13
|
-
import { t } from '../locales.ts'
|
|
14
|
-
import { CloseIcon, MaximizeIcon, PlusIcon, RestoreIcon, ShrinkIcon } from '../components/icons.tsx'
|
|
15
|
-
import { activateOnKey } from '../components/a11y.ts'
|
|
16
|
-
import previewCss from '../styles/preview.module.css'
|
|
17
|
-
|
|
18
|
-
/** Tab width cap (FileManager measured). */
|
|
19
|
-
export const MAX_TAB_WIDTH_PX = 180
|
|
20
|
-
/** Fade indicator width. */
|
|
21
|
-
const FADE_WIDTH = 32
|
|
22
|
-
|
|
23
|
-
/** Left/right overflow state. */
|
|
24
|
-
export interface TabFadeState {
|
|
25
|
-
left: boolean
|
|
26
|
-
right: boolean
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** The tab strip. */
|
|
30
|
-
export function PreviewTabs({
|
|
31
|
-
tabs,
|
|
32
|
-
activeTabId,
|
|
33
|
-
onSwitch,
|
|
34
|
-
onClose,
|
|
35
|
-
onContextMenu,
|
|
36
|
-
onNewUrlTab,
|
|
37
|
-
onClosePanel,
|
|
38
|
-
maximized,
|
|
39
|
-
onMaximize,
|
|
40
|
-
}: {
|
|
41
|
-
tabs: PreviewTabState[]
|
|
42
|
-
activeTabId: string | null
|
|
43
|
-
onSwitch: (id: string) => void
|
|
44
|
-
onClose: (id: string) => void
|
|
45
|
-
onContextMenu: (event: React.MouseEvent, tab: PreviewTabState) => void
|
|
46
|
-
onNewUrlTab: () => void
|
|
47
|
-
onClosePanel: () => void
|
|
48
|
-
maximized: boolean
|
|
49
|
-
onMaximize: () => void
|
|
50
|
-
}): JSX.Element {
|
|
51
|
-
const scrollRef = useRef<HTMLDivElement>(null)
|
|
52
|
-
const [fade, setFade] = useState<TabFadeState>({ left: false, right: false })
|
|
53
|
-
|
|
54
|
-
// Overflow fades: ResizeObserver + scroll listener, setState only on change.
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
const el = scrollRef.current
|
|
57
|
-
if (el === null) return
|
|
58
|
-
const update = (): void => {
|
|
59
|
-
const next = {
|
|
60
|
-
left: el.scrollLeft > 1,
|
|
61
|
-
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
|
|
62
|
-
}
|
|
63
|
-
setFade((prev) => (prev.left === next.left && prev.right === next.right ? prev : next))
|
|
64
|
-
}
|
|
65
|
-
const observer = new ResizeObserver(update)
|
|
66
|
-
observer.observe(el)
|
|
67
|
-
el.addEventListener('scroll', update, { passive: true })
|
|
68
|
-
window.addEventListener('resize', update)
|
|
69
|
-
update()
|
|
70
|
-
return () => {
|
|
71
|
-
observer.disconnect()
|
|
72
|
-
el.removeEventListener('scroll', update)
|
|
73
|
-
window.removeEventListener('resize', update)
|
|
74
|
-
}
|
|
75
|
-
}, [tabs.length])
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<div className={previewCss.tabBar}>
|
|
79
|
-
<div ref={scrollRef} className={previewCss.tabScroll}>
|
|
80
|
-
{tabs.length === 0 && <div className={previewCss.noTabs}>{t('preview.noTabs')}</div>}
|
|
81
|
-
{tabs.map((tab) => (
|
|
82
|
-
<div
|
|
83
|
-
key={tab.id}
|
|
84
|
-
className={`${previewCss.tab}${tab.id === activeTabId ? ` ${previewCss.tabActive}` : ` ${previewCss.tabInactive}`}`}
|
|
85
|
-
style={{ maxWidth: MAX_TAB_WIDTH_PX }}
|
|
86
|
-
role="button"
|
|
87
|
-
tabIndex={0}
|
|
88
|
-
title={tab.path}
|
|
89
|
-
aria-label={tab.title}
|
|
90
|
-
onClick={() => onSwitch(tab.id)}
|
|
91
|
-
onKeyDown={activateOnKey(() => { onSwitch(tab.id) })}
|
|
92
|
-
onContextMenu={(event) => onContextMenu(event, tab)}
|
|
93
|
-
onAuxClick={(event) => {
|
|
94
|
-
if (event.button !== 1) return
|
|
95
|
-
event.preventDefault()
|
|
96
|
-
event.stopPropagation()
|
|
97
|
-
onClose(tab.id)
|
|
98
|
-
}}
|
|
99
|
-
>
|
|
100
|
-
<span className={previewCss.tabTitle} title={tab.path}>{tab.title}</span>
|
|
101
|
-
{tab.dirty && <span className={previewCss.tabDotDirty} title={t('preview.dirty')} />}
|
|
102
|
-
<span
|
|
103
|
-
className={previewCss.tabClose}
|
|
104
|
-
role="button"
|
|
105
|
-
tabIndex={0}
|
|
106
|
-
title={t('common.close')}
|
|
107
|
-
aria-label={t('common.close')}
|
|
108
|
-
onClick={(event) => {
|
|
109
|
-
event.stopPropagation()
|
|
110
|
-
onClose(tab.id)
|
|
111
|
-
}}
|
|
112
|
-
onKeyDown={activateOnKey(() => { onClose(tab.id) })}
|
|
113
|
-
>
|
|
114
|
-
<CloseIcon size={12} />
|
|
115
|
-
</span>
|
|
116
|
-
</div>
|
|
117
|
-
))}
|
|
118
|
-
<div
|
|
119
|
-
className={previewCss.tabPlus}
|
|
120
|
-
role="button"
|
|
121
|
-
tabIndex={0}
|
|
122
|
-
onClick={onNewUrlTab}
|
|
123
|
-
onKeyDown={activateOnKey(onNewUrlTab)}
|
|
124
|
-
title={t('preview.newUrlTab')}
|
|
125
|
-
>
|
|
126
|
-
<PlusIcon size={14} />
|
|
127
|
-
</div>
|
|
128
|
-
</div>
|
|
129
|
-
<div className={previewCss.tabBarRight}>
|
|
130
|
-
{/* Maximize/restore (issue #315): the layout controller owns the grid
|
|
131
|
-
takeover, Esc and the restore path. */}
|
|
132
|
-
<div
|
|
133
|
-
className={previewCss.panelMaximize}
|
|
134
|
-
role="button"
|
|
135
|
-
tabIndex={0}
|
|
136
|
-
onClick={onMaximize}
|
|
137
|
-
onKeyDown={activateOnKey(onMaximize)}
|
|
138
|
-
title={t(maximized ? 'preview.restore' : 'preview.maximize')}
|
|
139
|
-
aria-label={t(maximized ? 'preview.restore' : 'preview.maximize')}
|
|
140
|
-
>
|
|
141
|
-
{maximized ? <RestoreIcon size={14} /> : <MaximizeIcon size={14} />}
|
|
142
|
-
</div>
|
|
143
|
-
<div
|
|
144
|
-
className={previewCss.panelCollapse}
|
|
145
|
-
role="button"
|
|
146
|
-
tabIndex={0}
|
|
147
|
-
onClick={onClosePanel}
|
|
148
|
-
onKeyDown={activateOnKey(onClosePanel)}
|
|
149
|
-
title={t('preview.collapsePanel')}
|
|
150
|
-
aria-label={t('preview.collapsePanel')}
|
|
151
|
-
>
|
|
152
|
-
<ShrinkIcon size={14} />
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
{fade.left && <div className={previewCss.tabFadeLeft} style={{ width: FADE_WIDTH }} />}
|
|
156
|
-
{fade.right && <div className={previewCss.tabFadeRight} style={{ width: FADE_WIDTH }} />}
|
|
157
|
-
</div>
|
|
158
|
-
)
|
|
159
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The preview tab strip: 36px bar, tabs capped at 180px (padding 0 10, gap 6,
|
|
3
|
+
* 12px title), dirty dot (6px, primary), close glyph (16px box, 12px icon),
|
|
4
|
+
* middle-click close, right-click menu (close left/right/others/all), the
|
|
5
|
+
* 32px left/right overflow fade indicators (ResizeObserver + scroll), the
|
|
6
|
+
* new-URL-tab plus, and the panel collapse button.
|
|
7
|
+
* @module dsh-filemgr/client/preview/PreviewTabs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
|
+
import type { JSX } from 'react'
|
|
12
|
+
import type { PreviewTabState } from '../store.ts'
|
|
13
|
+
import { t } from '../locales.ts'
|
|
14
|
+
import { CloseIcon, MaximizeIcon, PlusIcon, RestoreIcon, ShrinkIcon } from '../components/icons.tsx'
|
|
15
|
+
import { activateOnKey } from '../components/a11y.ts'
|
|
16
|
+
import previewCss from '../styles/preview.module.css'
|
|
17
|
+
|
|
18
|
+
/** Tab width cap (FileManager measured). */
|
|
19
|
+
export const MAX_TAB_WIDTH_PX = 180
|
|
20
|
+
/** Fade indicator width. */
|
|
21
|
+
const FADE_WIDTH = 32
|
|
22
|
+
|
|
23
|
+
/** Left/right overflow state. */
|
|
24
|
+
export interface TabFadeState {
|
|
25
|
+
left: boolean
|
|
26
|
+
right: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The tab strip. */
|
|
30
|
+
export function PreviewTabs({
|
|
31
|
+
tabs,
|
|
32
|
+
activeTabId,
|
|
33
|
+
onSwitch,
|
|
34
|
+
onClose,
|
|
35
|
+
onContextMenu,
|
|
36
|
+
onNewUrlTab,
|
|
37
|
+
onClosePanel,
|
|
38
|
+
maximized,
|
|
39
|
+
onMaximize,
|
|
40
|
+
}: {
|
|
41
|
+
tabs: PreviewTabState[]
|
|
42
|
+
activeTabId: string | null
|
|
43
|
+
onSwitch: (id: string) => void
|
|
44
|
+
onClose: (id: string) => void
|
|
45
|
+
onContextMenu: (event: React.MouseEvent, tab: PreviewTabState) => void
|
|
46
|
+
onNewUrlTab: () => void
|
|
47
|
+
onClosePanel: () => void
|
|
48
|
+
maximized: boolean
|
|
49
|
+
onMaximize: () => void
|
|
50
|
+
}): JSX.Element {
|
|
51
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
52
|
+
const [fade, setFade] = useState<TabFadeState>({ left: false, right: false })
|
|
53
|
+
|
|
54
|
+
// Overflow fades: ResizeObserver + scroll listener, setState only on change.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const el = scrollRef.current
|
|
57
|
+
if (el === null) return
|
|
58
|
+
const update = (): void => {
|
|
59
|
+
const next = {
|
|
60
|
+
left: el.scrollLeft > 1,
|
|
61
|
+
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
|
|
62
|
+
}
|
|
63
|
+
setFade((prev) => (prev.left === next.left && prev.right === next.right ? prev : next))
|
|
64
|
+
}
|
|
65
|
+
const observer = new ResizeObserver(update)
|
|
66
|
+
observer.observe(el)
|
|
67
|
+
el.addEventListener('scroll', update, { passive: true })
|
|
68
|
+
window.addEventListener('resize', update)
|
|
69
|
+
update()
|
|
70
|
+
return () => {
|
|
71
|
+
observer.disconnect()
|
|
72
|
+
el.removeEventListener('scroll', update)
|
|
73
|
+
window.removeEventListener('resize', update)
|
|
74
|
+
}
|
|
75
|
+
}, [tabs.length])
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={previewCss.tabBar}>
|
|
79
|
+
<div ref={scrollRef} className={previewCss.tabScroll}>
|
|
80
|
+
{tabs.length === 0 && <div className={previewCss.noTabs}>{t('preview.noTabs')}</div>}
|
|
81
|
+
{tabs.map((tab) => (
|
|
82
|
+
<div
|
|
83
|
+
key={tab.id}
|
|
84
|
+
className={`${previewCss.tab}${tab.id === activeTabId ? ` ${previewCss.tabActive}` : ` ${previewCss.tabInactive}`}`}
|
|
85
|
+
style={{ maxWidth: MAX_TAB_WIDTH_PX }}
|
|
86
|
+
role="button"
|
|
87
|
+
tabIndex={0}
|
|
88
|
+
title={tab.path}
|
|
89
|
+
aria-label={tab.title}
|
|
90
|
+
onClick={() => onSwitch(tab.id)}
|
|
91
|
+
onKeyDown={activateOnKey(() => { onSwitch(tab.id) })}
|
|
92
|
+
onContextMenu={(event) => onContextMenu(event, tab)}
|
|
93
|
+
onAuxClick={(event) => {
|
|
94
|
+
if (event.button !== 1) return
|
|
95
|
+
event.preventDefault()
|
|
96
|
+
event.stopPropagation()
|
|
97
|
+
onClose(tab.id)
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<span className={previewCss.tabTitle} title={tab.path}>{tab.title}</span>
|
|
101
|
+
{tab.dirty && <span className={previewCss.tabDotDirty} title={t('preview.dirty')} />}
|
|
102
|
+
<span
|
|
103
|
+
className={previewCss.tabClose}
|
|
104
|
+
role="button"
|
|
105
|
+
tabIndex={0}
|
|
106
|
+
title={t('common.close')}
|
|
107
|
+
aria-label={t('common.close')}
|
|
108
|
+
onClick={(event) => {
|
|
109
|
+
event.stopPropagation()
|
|
110
|
+
onClose(tab.id)
|
|
111
|
+
}}
|
|
112
|
+
onKeyDown={activateOnKey(() => { onClose(tab.id) })}
|
|
113
|
+
>
|
|
114
|
+
<CloseIcon size={12} />
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
<div
|
|
119
|
+
className={previewCss.tabPlus}
|
|
120
|
+
role="button"
|
|
121
|
+
tabIndex={0}
|
|
122
|
+
onClick={onNewUrlTab}
|
|
123
|
+
onKeyDown={activateOnKey(onNewUrlTab)}
|
|
124
|
+
title={t('preview.newUrlTab')}
|
|
125
|
+
>
|
|
126
|
+
<PlusIcon size={14} />
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
<div className={previewCss.tabBarRight}>
|
|
130
|
+
{/* Maximize/restore (issue #315): the layout controller owns the grid
|
|
131
|
+
takeover, Esc and the restore path. */}
|
|
132
|
+
<div
|
|
133
|
+
className={previewCss.panelMaximize}
|
|
134
|
+
role="button"
|
|
135
|
+
tabIndex={0}
|
|
136
|
+
onClick={onMaximize}
|
|
137
|
+
onKeyDown={activateOnKey(onMaximize)}
|
|
138
|
+
title={t(maximized ? 'preview.restore' : 'preview.maximize')}
|
|
139
|
+
aria-label={t(maximized ? 'preview.restore' : 'preview.maximize')}
|
|
140
|
+
>
|
|
141
|
+
{maximized ? <RestoreIcon size={14} /> : <MaximizeIcon size={14} />}
|
|
142
|
+
</div>
|
|
143
|
+
<div
|
|
144
|
+
className={previewCss.panelCollapse}
|
|
145
|
+
role="button"
|
|
146
|
+
tabIndex={0}
|
|
147
|
+
onClick={onClosePanel}
|
|
148
|
+
onKeyDown={activateOnKey(onClosePanel)}
|
|
149
|
+
title={t('preview.collapsePanel')}
|
|
150
|
+
aria-label={t('preview.collapsePanel')}
|
|
151
|
+
>
|
|
152
|
+
<ShrinkIcon size={14} />
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
{fade.left && <div className={previewCss.tabFadeLeft} style={{ width: FADE_WIDTH }} />}
|
|
156
|
+
{fade.right && <div className={previewCss.tabFadeRight} style={{ width: FADE_WIDTH }} />}
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
import type { JSX } from 'react'
|
|
10
10
|
import type { PreviewContentType } from '../../core/types.ts'
|
|
11
|
-
import { isEditableType } from '../fileType.ts'
|
|
12
11
|
import { t } from '../locales.ts'
|
|
13
12
|
import { CodeIcon, DownloadIcon, EyeIcon, RefreshIcon, SaveIcon, SplitIcon } from '../components/icons.tsx'
|
|
14
13
|
import previewCss from '../styles/preview.module.css'
|
|
@@ -65,6 +64,7 @@ export function PreviewToolbar({
|
|
|
65
64
|
canToggleView,
|
|
66
65
|
split,
|
|
67
66
|
canSplit,
|
|
67
|
+
editable,
|
|
68
68
|
onViewModeChange,
|
|
69
69
|
onSplitChange,
|
|
70
70
|
onRefresh,
|
|
@@ -80,6 +80,8 @@ export function PreviewToolbar({
|
|
|
80
80
|
canToggleView: boolean
|
|
81
81
|
split: boolean
|
|
82
82
|
canSplit: boolean
|
|
83
|
+
/** The tab can be edited and saved back (text kinds + editable .xlsx). */
|
|
84
|
+
editable: boolean
|
|
83
85
|
onViewModeChange: (mode: 'source' | 'preview') => void
|
|
84
86
|
onSplitChange: (split: boolean) => void
|
|
85
87
|
onRefresh: () => void
|
|
@@ -87,7 +89,6 @@ export function PreviewToolbar({
|
|
|
87
89
|
onDownload: () => void
|
|
88
90
|
}): JSX.Element {
|
|
89
91
|
const refreshState = refreshStateFor(contentType, hasContent, loading, updated)
|
|
90
|
-
const editable = isEditableType(contentType)
|
|
91
92
|
|
|
92
93
|
return (
|
|
93
94
|
<div className={previewCss.toolbar}>
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
watchShellTheme,
|
|
24
24
|
} from './mermaid.ts'
|
|
25
25
|
import previewCss from '../styles/preview.module.css'
|
|
26
|
+
import { OfficeViewer } from './office.tsx'
|
|
26
27
|
|
|
27
28
|
/** Split-ratio persistence key (FileManager contract). */
|
|
28
29
|
export const KEY_SPLIT_RATIO = 'preview-panel-split-ratio'
|
|
@@ -32,12 +33,15 @@ export function TabContent({
|
|
|
32
33
|
tab,
|
|
33
34
|
viewMode,
|
|
34
35
|
split,
|
|
36
|
+
onInsertSheetReference,
|
|
35
37
|
onContentChange,
|
|
36
38
|
onSave,
|
|
37
39
|
}: {
|
|
38
40
|
tab: PreviewTabState
|
|
39
41
|
viewMode: 'source' | 'preview'
|
|
40
42
|
split: boolean
|
|
43
|
+
/** Insert a 文件!Sheet!Range chip from an Excel selection. */
|
|
44
|
+
onInsertSheetReference: (path: string, sheetName: string, range: string) => boolean
|
|
41
45
|
onContentChange: (content: string) => void
|
|
42
46
|
onSave: () => void
|
|
43
47
|
}): JSX.Element {
|
|
@@ -93,9 +97,10 @@ export function TabContent({
|
|
|
93
97
|
)}
|
|
94
98
|
{tab.contentType === 'pdf' && tab.content !== null && <PdfViewer dataUrl={tab.content} title={tab.title} />}
|
|
95
99
|
{tab.contentType === 'url' && <UrlViewer tab={tab} />}
|
|
96
|
-
{(tab.contentType === 'word' || tab.contentType === 'excel' || tab.contentType === 'ppt'
|
|
97
|
-
<
|
|
100
|
+
{(tab.contentType === 'word' || tab.contentType === 'excel' || tab.contentType === 'ppt') && tab.content !== null && (
|
|
101
|
+
<OfficeViewer key={tab.id} tab={tab} onInsertSheetReference={onInsertSheetReference} />
|
|
98
102
|
)}
|
|
103
|
+
{tab.contentType === 'unsupported' && <UnsupportedViewer tab={tab} />}
|
|
99
104
|
{tab.content === null && !tab.loading && (
|
|
100
105
|
<div className={previewCss.placeholder}>
|
|
101
106
|
<div className={previewCss.placeholderTitle}>{tab.title}</div>
|