@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.
Files changed (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. 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
+ }