@lijian-ui/dsh-file-manager 0.1.20
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 -0
- package/README.i18n.yaml +6 -0
- package/README.md +66 -0
- package/README.zh.md +66 -0
- package/cordis.patch.yml +13 -0
- package/lib/assets/mermaid.min.js +3587 -0
- package/lib/client.js +7621 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3207 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +104 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +75 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
- package/lib/types/client/chat/file-ref.d.ts +35 -0
- package/lib/types/client/chat/file-ref.d.ts.map +1 -0
- package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
- package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
- package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
- package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
- package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
- package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
- package/lib/types/client/components/FileIcon.d.ts +15 -0
- package/lib/types/client/components/FileIcon.d.ts.map +1 -0
- package/lib/types/client/components/ScmPanel.d.ts +23 -0
- package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
- package/lib/types/client/components/a11y.d.ts +12 -0
- package/lib/types/client/components/a11y.d.ts.map +1 -0
- package/lib/types/client/components/icons.d.ts +41 -0
- package/lib/types/client/components/icons.d.ts.map +1 -0
- package/lib/types/client/components/overlay.d.ts +45 -0
- package/lib/types/client/components/overlay.d.ts.map +1 -0
- package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
- package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
- package/lib/types/client/drag/file-drag.d.ts +47 -0
- package/lib/types/client/drag/file-drag.d.ts.map +1 -0
- package/lib/types/client/drag.d.ts +31 -0
- package/lib/types/client/drag.d.ts.map +1 -0
- package/lib/types/client/file-source.d.ts +29 -0
- package/lib/types/client/file-source.d.ts.map +1 -0
- package/lib/types/client/fileType.d.ts +31 -0
- package/lib/types/client/fileType.d.ts.map +1 -0
- package/lib/types/client/floating.d.ts +27 -0
- package/lib/types/client/floating.d.ts.map +1 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
- package/lib/types/client/hooks/useStore.d.ts +10 -0
- package/lib/types/client/hooks/useStore.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/layout.d.ts +104 -0
- package/lib/types/client/layout.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +125 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/maximize.d.ts +24 -0
- package/lib/types/client/maximize.d.ts.map +1 -0
- package/lib/types/client/mention.d.ts +44 -0
- package/lib/types/client/mention.d.ts.map +1 -0
- package/lib/types/client/mount.d.ts +22 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/persist.d.ts +55 -0
- package/lib/types/client/persist.d.ts.map +1 -0
- package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
- package/lib/types/client/picker/file-picker.d.ts +62 -0
- package/lib/types/client/picker/file-picker.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
- package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
- package/lib/types/client/preview/content.d.ts +43 -0
- package/lib/types/client/preview/content.d.ts.map +1 -0
- package/lib/types/client/preview/markdown.d.ts +60 -0
- package/lib/types/client/preview/markdown.d.ts.map +1 -0
- package/lib/types/client/preview/mermaid.d.ts +78 -0
- package/lib/types/client/preview/mermaid.d.ts.map +1 -0
- package/lib/types/client/preview/selection.d.ts +26 -0
- package/lib/types/client/preview/selection.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +75 -0
- package/lib/types/client/reference.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +206 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +279 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +132 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/fs-service.d.ts +96 -0
- package/lib/types/host/fs-service.d.ts.map +1 -0
- package/lib/types/host/gate.d.ts +45 -0
- package/lib/types/host/gate.d.ts.map +1 -0
- package/lib/types/host/git-runner.d.ts +80 -0
- package/lib/types/host/git-runner.d.ts.map +1 -0
- package/lib/types/host/git-service.d.ts +130 -0
- package/lib/types/host/git-service.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/poll-guard.d.ts +55 -0
- package/lib/types/host/poll-guard.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +54 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/client/FileManagerSettingsCard.tsx +117 -0
- package/src/client/PluginSettingsCard.tsx +337 -0
- package/src/client/api.ts +141 -0
- package/src/client/chat/file-ref.ts +144 -0
- package/src/client/chat/mermaid-chat.tsx +102 -0
- package/src/client/chat/placeholder-hint.tsx +46 -0
- package/src/client/components/ExplorerPanel.tsx +534 -0
- package/src/client/components/FileIcon.tsx +46 -0
- package/src/client/components/ScmPanel.tsx +480 -0
- package/src/client/components/a11y.ts +20 -0
- package/src/client/components/icons.tsx +274 -0
- package/src/client/components/overlay.tsx +225 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag/DragFileInlay.tsx +89 -0
- package/src/client/drag/file-drag.ts +67 -0
- package/src/client/drag.ts +154 -0
- package/src/client/file-source.ts +58 -0
- package/src/client/fileType.ts +129 -0
- package/src/client/floating.ts +63 -0
- package/src/client/hooks/useResizableSplit.ts +91 -0
- package/src/client/hooks/useStore.ts +15 -0
- package/src/client/index.ts +389 -0
- package/src/client/layout.ts +546 -0
- package/src/client/locales.ts +247 -0
- package/src/client/maximize.ts +34 -0
- package/src/client/mount.tsx +74 -0
- package/src/client/persist.ts +194 -0
- package/src/client/picker/FilePickerModal.tsx +506 -0
- package/src/client/picker/file-picker.ts +86 -0
- package/src/client/preview/PreviewPanel.tsx +233 -0
- package/src/client/preview/PreviewTabs.tsx +159 -0
- package/src/client/preview/PreviewToolbar.tsx +160 -0
- package/src/client/preview/content.tsx +617 -0
- package/src/client/preview/markdown.ts +351 -0
- package/src/client/preview/mermaid.ts +274 -0
- package/src/client/preview/selection.ts +82 -0
- package/src/client/reference.ts +161 -0
- package/src/client/settings-card.module.css +316 -0
- package/src/client/settings-form.ts +451 -0
- package/src/client/store.ts +1290 -0
- package/src/client/styles/chip.module.css +38 -0
- package/src/client/styles/drag.module.css +30 -0
- package/src/client/styles/explorer.module.css +371 -0
- package/src/client/styles/picker.module.css +344 -0
- package/src/client/styles/preview.module.css +904 -0
- package/src/client/styles/scm.module.css +380 -0
- package/src/client/styles/tokens.module.css +480 -0
- package/src/core/types.ts +162 -0
- package/src/host/fs-service.ts +513 -0
- package/src/host/gate.ts +74 -0
- package/src/host/git-runner.ts +116 -0
- package/src/host/git-service.ts +395 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/poll-guard.ts +109 -0
- package/src/host/routes.ts +695 -0
- package/src/index.ts +97 -0
- package/src/mount-once.ts +48 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview panel root: tab strip + toolbar + content router, the tab
|
|
3
|
+
* context menu (close left/right/others/all), the dirty-close confirmation
|
|
4
|
+
* (the single entry for every batch close — middle-click included), and the
|
|
5
|
+
* panel collapse button. View mode and split live here so the toolbar and the
|
|
6
|
+
* content share one source; both reset when the displayed file changes.
|
|
7
|
+
* @module dsh-filemgr/client/preview/PreviewPanel
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
|
+
import type { JSX } from 'react'
|
|
12
|
+
import { isEditableType } from '../fileType.ts'
|
|
13
|
+
import { t, format } from '../locales.ts'
|
|
14
|
+
import { useStore } from '../hooks/useStore.ts'
|
|
15
|
+
import type { PanelStores, PreviewTabState } from '../store.ts'
|
|
16
|
+
import { ConfirmDialog, ContextMenu, type MenuState } from '../components/overlay.tsx'
|
|
17
|
+
import { PreviewTabs } from './PreviewTabs.tsx'
|
|
18
|
+
import { PreviewToolbar, downloadTab } from './PreviewToolbar.tsx'
|
|
19
|
+
import { TabContent } from './content.tsx'
|
|
20
|
+
import { findFileReferenceSelection, type FileReferenceSelection } from './selection.ts'
|
|
21
|
+
import previewCss from '../styles/preview.module.css'
|
|
22
|
+
|
|
23
|
+
/** Coords + a fully-resolved file-reference payload (path + line range). */
|
|
24
|
+
interface AddToChatState {
|
|
25
|
+
x: number
|
|
26
|
+
y: number
|
|
27
|
+
ref: FileReferenceSelection
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The preview panel (mounted in the preview grid column). */
|
|
31
|
+
export function PreviewPanel({ stores, onAddFileReference }: { stores: PanelStores; onAddFileReference: (selection: FileReferenceSelection) => boolean }): JSX.Element {
|
|
32
|
+
const preview = stores.preview
|
|
33
|
+
const state = useStore(preview)
|
|
34
|
+
const layoutState = useStore(stores.layout)
|
|
35
|
+
const maximizedPreview = layoutState.maximized === 'preview'
|
|
36
|
+
const [menu, setMenu] = useState<MenuState | null>(null)
|
|
37
|
+
const [closingIds, setClosingIds] = useState<string[] | null>(null)
|
|
38
|
+
const [viewMode, setViewMode] = useState<'source' | 'preview'>('preview')
|
|
39
|
+
const [split, setSplit] = useState(false)
|
|
40
|
+
const lastDirtyCheck = useRef<Set<string>>(new Set())
|
|
41
|
+
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
42
|
+
|
|
43
|
+
/** The "add selection to the composer" floating action. */
|
|
44
|
+
const [addToChat, setAddToChat] = useState<AddToChatState | null>(null)
|
|
45
|
+
|
|
46
|
+
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
|
|
47
|
+
|
|
48
|
+
// View mode resets to preview when the displayed FILE changes (path+type).
|
|
49
|
+
const identity = activeTab === null ? '' : `${activeTab.path}\u0000${activeTab.contentType}`
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setViewMode('preview')
|
|
52
|
+
setSplit(false)
|
|
53
|
+
}, [identity])
|
|
54
|
+
|
|
55
|
+
// Selection → "添加到对话" floating action: a non-collapsed selection inside
|
|
56
|
+
// a file-backed (code/text) preview is translated into a file reference
|
|
57
|
+
// (path + start/end line) and surfaces a button under the selection.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const onSelectionChange = (): void => {
|
|
60
|
+
const selection = window.getSelection()
|
|
61
|
+
if (selection === null || selection.isCollapsed || selection.rangeCount === 0) {
|
|
62
|
+
setAddToChat(null)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const node = selection.anchorNode
|
|
66
|
+
const el = node instanceof Element ? node : node?.parentElement
|
|
67
|
+
if (el === undefined || panelRef.current === null || !panelRef.current.contains(el)) {
|
|
68
|
+
setAddToChat(null)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const ref = findFileReferenceSelection(el, selection, activeTab)
|
|
72
|
+
if (ref === null) {
|
|
73
|
+
setAddToChat(null)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const rect = selection.getRangeAt(0).getBoundingClientRect()
|
|
77
|
+
setAddToChat({ x: rect.left, y: rect.bottom + 8, ref })
|
|
78
|
+
}
|
|
79
|
+
const onPointerDown = (event: MouseEvent): void => {
|
|
80
|
+
if (addToChat === null) return
|
|
81
|
+
const target = event.target as Element
|
|
82
|
+
if (target.closest(`.${previewCss.addToChat}`) === null) setAddToChat(null)
|
|
83
|
+
}
|
|
84
|
+
document.addEventListener('selectionchange', onSelectionChange)
|
|
85
|
+
document.addEventListener('mousedown', onPointerDown)
|
|
86
|
+
return () => {
|
|
87
|
+
document.removeEventListener('selectionchange', onSelectionChange)
|
|
88
|
+
document.removeEventListener('mousedown', onPointerDown)
|
|
89
|
+
}
|
|
90
|
+
}, [addToChat, activeTab])
|
|
91
|
+
|
|
92
|
+
const handleAddToChat = (): void => {
|
|
93
|
+
if (addToChat === null) return
|
|
94
|
+
if (onAddFileReference(addToChat.ref)) setAddToChat(null)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Close a batch; dirty tabs route through the confirmation first. */
|
|
98
|
+
const requestClose = (ids: string[]): void => {
|
|
99
|
+
const dirty = state.tabs.filter((tab) => ids.includes(tab.id) && tab.dirty)
|
|
100
|
+
if (dirty.length === 0) {
|
|
101
|
+
preview.closeTabs(ids)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
lastDirtyCheck.current = new Set(dirty.map((tab) => tab.id))
|
|
105
|
+
setClosingIds(ids)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const closeMenuFor = (event: React.MouseEvent, tab: PreviewTabState): void => {
|
|
109
|
+
event.preventDefault()
|
|
110
|
+
event.stopPropagation()
|
|
111
|
+
const index = state.tabs.findIndex((item) => item.id === tab.id)
|
|
112
|
+
setMenu({
|
|
113
|
+
x: event.clientX,
|
|
114
|
+
y: event.clientY,
|
|
115
|
+
entries: [
|
|
116
|
+
{
|
|
117
|
+
key: 'close-left',
|
|
118
|
+
label: t('preview.closeLeft'),
|
|
119
|
+
disabled: index <= 0,
|
|
120
|
+
onSelect: () => requestClose(state.tabs.slice(0, index).map((item) => item.id)),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
key: 'close-right',
|
|
124
|
+
label: t('preview.closeRight'),
|
|
125
|
+
disabled: index >= state.tabs.length - 1,
|
|
126
|
+
onSelect: () => requestClose(state.tabs.slice(index + 1).map((item) => item.id)),
|
|
127
|
+
},
|
|
128
|
+
{ key: 'sep-1', label: '---', onSelect: () => {} },
|
|
129
|
+
{
|
|
130
|
+
key: 'close-others',
|
|
131
|
+
label: t('preview.closeOthers'),
|
|
132
|
+
disabled: state.tabs.length <= 1,
|
|
133
|
+
onSelect: () => requestClose(state.tabs.filter((item) => item.id !== tab.id).map((item) => item.id)),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: 'close-all',
|
|
137
|
+
label: t('preview.closeAll'),
|
|
138
|
+
onSelect: () => requestClose(state.tabs.map((item) => item.id)),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A fresh url tab (empty address; the viewer owns the input). */
|
|
145
|
+
const newUrlTab = (): void => {
|
|
146
|
+
const stamp = Date.now()
|
|
147
|
+
const tab: PreviewTabState = {
|
|
148
|
+
id: `url:${stamp}`,
|
|
149
|
+
title: 'new tab',
|
|
150
|
+
root: state.root,
|
|
151
|
+
path: `url:${stamp}`,
|
|
152
|
+
contentType: 'url',
|
|
153
|
+
content: '',
|
|
154
|
+
dirty: false,
|
|
155
|
+
updated: false,
|
|
156
|
+
loading: false,
|
|
157
|
+
truncated: false,
|
|
158
|
+
error: null,
|
|
159
|
+
savedAt: Date.now(),
|
|
160
|
+
}
|
|
161
|
+
preview.update((prev) => ({ ...prev, open: true, tabs: [...prev.tabs, tab], activeTabId: tab.id }))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div ref={panelRef} className={`filemgr-root ${previewCss.panel}`}>
|
|
166
|
+
<PreviewTabs
|
|
167
|
+
tabs={state.tabs}
|
|
168
|
+
activeTabId={state.activeTabId}
|
|
169
|
+
onSwitch={(id) => preview.switchTab(id)}
|
|
170
|
+
onClose={(id) => requestClose([id])}
|
|
171
|
+
onContextMenu={closeMenuFor}
|
|
172
|
+
onNewUrlTab={newUrlTab}
|
|
173
|
+
onClosePanel={() => preview.setOpen(false)}
|
|
174
|
+
maximized={maximizedPreview}
|
|
175
|
+
onMaximize={() => {
|
|
176
|
+
stores.layout.update((prev) => ({ ...prev, maximized: maximizedPreview ? null : 'preview' }))
|
|
177
|
+
}}
|
|
178
|
+
/>
|
|
179
|
+
{activeTab !== null && (
|
|
180
|
+
<>
|
|
181
|
+
<PreviewToolbar
|
|
182
|
+
contentType={activeTab.contentType}
|
|
183
|
+
hasContent={activeTab.content !== null}
|
|
184
|
+
loading={activeTab.loading}
|
|
185
|
+
dirty={activeTab.dirty}
|
|
186
|
+
updated={activeTab.updated}
|
|
187
|
+
viewMode={viewMode}
|
|
188
|
+
canToggleView={activeTab.contentType === 'markdown' || activeTab.contentType === 'html'}
|
|
189
|
+
split={split}
|
|
190
|
+
canSplit={isEditableType(activeTab.contentType) && activeTab.content !== null}
|
|
191
|
+
onViewModeChange={setViewMode}
|
|
192
|
+
onSplitChange={setSplit}
|
|
193
|
+
onRefresh={() => void preview.reloadTab(activeTab.id)}
|
|
194
|
+
onSave={() => void preview.saveTab(activeTab.id)}
|
|
195
|
+
onDownload={() => downloadTab(activeTab)}
|
|
196
|
+
/>
|
|
197
|
+
<TabContent
|
|
198
|
+
tab={activeTab}
|
|
199
|
+
viewMode={viewMode}
|
|
200
|
+
split={split}
|
|
201
|
+
onContentChange={(content) => preview.updateContent(activeTab.id, content)}
|
|
202
|
+
onSave={() => void preview.saveTab(activeTab.id)}
|
|
203
|
+
/>
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
{menu !== null && <ContextMenu state={menu} onClose={() => setMenu(null)} />}
|
|
207
|
+
{addToChat !== null && (
|
|
208
|
+
<div
|
|
209
|
+
className={previewCss.addToChat}
|
|
210
|
+
style={{ left: addToChat.x, top: addToChat.y }}
|
|
211
|
+
onClick={handleAddToChat}
|
|
212
|
+
role="button"
|
|
213
|
+
tabIndex={-1}
|
|
214
|
+
>
|
|
215
|
+
添加到对话
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
{closingIds !== null && (
|
|
219
|
+
<ConfirmDialog
|
|
220
|
+
title={t('preview.closeConfirmTitle')}
|
|
221
|
+
body={format(t('preview.closeConfirmBody'), { count: lastDirtyCheck.current.size })}
|
|
222
|
+
confirmLabel={t('common.close')}
|
|
223
|
+
danger
|
|
224
|
+
onConfirm={() => {
|
|
225
|
+
preview.closeTabs(closingIds)
|
|
226
|
+
setClosingIds(null)
|
|
227
|
+
}}
|
|
228
|
+
onCancel={() => setClosingIds(null)}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview toolbar: 32px bar (padding 0 10). Left: source/preview toggle
|
|
3
|
+
* (markdown/html), split-screen toggle (editable types), download. Right: the
|
|
4
|
+
* refresh button (4-state: hidden/disabled/idle/updated — never a dead
|
|
5
|
+
* button) and save (editable + dirty, Cmd/Ctrl+S too).
|
|
6
|
+
* @module dsh-filemgr/client/preview/PreviewToolbar
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { JSX } from 'react'
|
|
10
|
+
import type { PreviewContentType } from '../../core/types.ts'
|
|
11
|
+
import { isEditableType } from '../fileType.ts'
|
|
12
|
+
import { t } from '../locales.ts'
|
|
13
|
+
import { CodeIcon, DownloadIcon, EyeIcon, RefreshIcon, SaveIcon, SplitIcon } from '../components/icons.tsx'
|
|
14
|
+
import previewCss from '../styles/preview.module.css'
|
|
15
|
+
|
|
16
|
+
/** Refresh button states (FileManager's 4-state machine). */
|
|
17
|
+
export type RefreshState = 'hidden' | 'disabled' | 'idle' | 'updated'
|
|
18
|
+
|
|
19
|
+
/** Derive the refresh state for one tab. */
|
|
20
|
+
export function refreshStateFor(
|
|
21
|
+
contentType: PreviewContentType,
|
|
22
|
+
hasContent: boolean,
|
|
23
|
+
loading: boolean,
|
|
24
|
+
updated: boolean,
|
|
25
|
+
): RefreshState {
|
|
26
|
+
// URL tabs reload their frame (cross-origin documents can only be
|
|
27
|
+
// re-navigated to the tab's address, never reloaded in place).
|
|
28
|
+
if (contentType === 'url') return 'idle'
|
|
29
|
+
if (contentType === 'word' || contentType === 'excel'
|
|
30
|
+
|| contentType === 'ppt' || contentType === 'unsupported' || contentType === 'image') {
|
|
31
|
+
return 'hidden'
|
|
32
|
+
}
|
|
33
|
+
if (!hasContent || loading) return 'disabled'
|
|
34
|
+
return updated ? 'updated' : 'idle'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Download the current tab's content as a file. */
|
|
38
|
+
export function downloadTab(tab: { title: string; content: string | null; contentType: PreviewContentType }): void {
|
|
39
|
+
if (tab.content === null) return
|
|
40
|
+
const isDataUrl = tab.content.startsWith('data:')
|
|
41
|
+
// Pdf tabs hold a same-origin raw-route URL: anchor it directly (the
|
|
42
|
+
// download attribute forces a save), no blob copy needed.
|
|
43
|
+
const isRouteUrl = tab.content.startsWith('/filemgr/raw')
|
|
44
|
+
const href = isDataUrl || isRouteUrl
|
|
45
|
+
? tab.content
|
|
46
|
+
: URL.createObjectURL(new Blob([tab.content], { type: 'text/plain;charset=utf-8' }))
|
|
47
|
+
const anchor = document.createElement('a')
|
|
48
|
+
anchor.href = href
|
|
49
|
+
anchor.download = tab.title
|
|
50
|
+
anchor.style.display = 'none'
|
|
51
|
+
document.body.appendChild(anchor)
|
|
52
|
+
anchor.click()
|
|
53
|
+
anchor.remove()
|
|
54
|
+
if (!isDataUrl && !isRouteUrl) setTimeout(() => URL.revokeObjectURL(href), 10_000)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The toolbar. */
|
|
58
|
+
export function PreviewToolbar({
|
|
59
|
+
contentType,
|
|
60
|
+
hasContent,
|
|
61
|
+
loading,
|
|
62
|
+
dirty,
|
|
63
|
+
updated,
|
|
64
|
+
viewMode,
|
|
65
|
+
canToggleView,
|
|
66
|
+
split,
|
|
67
|
+
canSplit,
|
|
68
|
+
onViewModeChange,
|
|
69
|
+
onSplitChange,
|
|
70
|
+
onRefresh,
|
|
71
|
+
onSave,
|
|
72
|
+
onDownload,
|
|
73
|
+
}: {
|
|
74
|
+
contentType: PreviewContentType
|
|
75
|
+
hasContent: boolean
|
|
76
|
+
loading: boolean
|
|
77
|
+
dirty: boolean
|
|
78
|
+
updated: boolean
|
|
79
|
+
viewMode: 'source' | 'preview'
|
|
80
|
+
canToggleView: boolean
|
|
81
|
+
split: boolean
|
|
82
|
+
canSplit: boolean
|
|
83
|
+
onViewModeChange: (mode: 'source' | 'preview') => void
|
|
84
|
+
onSplitChange: (split: boolean) => void
|
|
85
|
+
onRefresh: () => void
|
|
86
|
+
onSave: () => void
|
|
87
|
+
onDownload: () => void
|
|
88
|
+
}): JSX.Element {
|
|
89
|
+
const refreshState = refreshStateFor(contentType, hasContent, loading, updated)
|
|
90
|
+
const editable = isEditableType(contentType)
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className={previewCss.toolbar}>
|
|
94
|
+
{canToggleView && (
|
|
95
|
+
<>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className={`${previewCss.toolbarBtn}${viewMode === 'source' ? ` ${previewCss.toolbarBtnActive}` : ''}`}
|
|
99
|
+
onClick={() => onViewModeChange('source')}
|
|
100
|
+
>
|
|
101
|
+
<CodeIcon size={13} />
|
|
102
|
+
{t('preview.source')}
|
|
103
|
+
</button>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
className={`${previewCss.toolbarBtn}${viewMode === 'preview' ? ` ${previewCss.toolbarBtnActive}` : ''}`}
|
|
107
|
+
onClick={() => onViewModeChange('preview')}
|
|
108
|
+
>
|
|
109
|
+
<EyeIcon size={13} />
|
|
110
|
+
{t('preview.preview')}
|
|
111
|
+
</button>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
{canSplit && (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
className={`${previewCss.toolbarBtn}${split ? ` ${previewCss.toolbarBtnActive}` : ''}`}
|
|
118
|
+
title={t('preview.split')}
|
|
119
|
+
onClick={() => onSplitChange(!split)}
|
|
120
|
+
>
|
|
121
|
+
<SplitIcon size={13} />
|
|
122
|
+
{t('preview.split')}
|
|
123
|
+
</button>
|
|
124
|
+
)}
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
className={previewCss.toolbarBtn}
|
|
128
|
+
title={t('preview.download')}
|
|
129
|
+
disabled={!hasContent}
|
|
130
|
+
onClick={onDownload}
|
|
131
|
+
>
|
|
132
|
+
<DownloadIcon size={13} />
|
|
133
|
+
</button>
|
|
134
|
+
<span className={previewCss.toolbarSpacer} />
|
|
135
|
+
{refreshState !== 'hidden' && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className={`${previewCss.toolbarBtn}${refreshState === 'updated' ? ` ${previewCss.toolbarBtnWarn}` : ''}`}
|
|
139
|
+
title={refreshState === 'updated' ? t('preview.refresh.updated') : t('preview.refresh')}
|
|
140
|
+
disabled={refreshState === 'disabled'}
|
|
141
|
+
onClick={onRefresh}
|
|
142
|
+
>
|
|
143
|
+
<RefreshIcon size={13} />
|
|
144
|
+
{t('preview.refresh')}
|
|
145
|
+
</button>
|
|
146
|
+
)}
|
|
147
|
+
{editable && dirty && (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
className={previewCss.toolbarBtn}
|
|
151
|
+
onClick={onSave}
|
|
152
|
+
disabled={loading}
|
|
153
|
+
>
|
|
154
|
+
<SaveIcon size={13} />
|
|
155
|
+
{t('preview.save')}
|
|
156
|
+
</button>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|