@yiln-dsh/dsh-plugin-file-explorer 0.4.0 → 0.4.1

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 (4) hide show
  1. package/README.md +16 -15
  2. package/client.js +230 -7
  3. package/index.js +24 -4
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-plugin-file-explorer
1
+ # @yiln-dsh/dsh-plugin-file-explorer
2
2
 
3
3
  A DSH `dsh.bundle` that replaces the built-in **details** column of the Web
4
4
  GUI with a workspace file explorer.
@@ -9,7 +9,8 @@ GUI with a workspace file explorer.
9
9
  - **Files** tab: lists the active session's workspace directory, with folder
10
10
  navigation, editable path input, parent/refresh buttons, file and folder
11
11
  icons, and file sizes. File rows reveal view / download / delete buttons on
12
- hover; delete uses a second-click confirm.
12
+ hover; delete uses a second-click confirm. Image View supports fullscreen,
13
+ 25%-400% zoom, wheel zoom, and drag-to-pan after zooming.
13
14
  - **Git Graph** tab: a vscode/le-git-graph style commit graph rendered from
14
15
  the repository containing the current directory —
15
16
  - colored lane graph with commit dots and merge curves (all branches or the
@@ -27,13 +28,12 @@ GUI with a workspace file explorer.
27
28
  the plugin captures the shell layout store actions when the root entry wires
28
29
  them, restores the saved width right after `openDetails()`, and saves the
29
30
  column width after every resizer drag.
30
- - **Collapse to icon bar**: a collapse button sits left of the header title
31
- (chevrons pointing right, matching the DSH session menu's compact icon
32
- style). Clicking it closes the details column and leaves a slim rail pinned
33
- to the right edge with two icons — **Files** and **Git Graph** clicking
34
- either reopens the drawer on that tab at the last chosen width. The layout
35
- keeps the slot subtree mounted at width 0, so the rail survives and the
36
- panel state is preserved.
31
+ - **Collapse to icon bar**: the collapse button matches the DSH session
32
+ menu's circular panel-toggle style, with the direction mirrored for the
33
+ right-side column. Clicking it keeps a real 56px rail in the layout with
34
+ **Files** and **Git Graph** icons; clicking either reopens that tab at the
35
+ last chosen width. The rail is not a floating overlay, and the panel state
36
+ is preserved.
37
37
 
38
38
  ## Layout
39
39
 
@@ -45,7 +45,7 @@ GUI with a workspace file explorer.
45
45
 
46
46
  ## Install
47
47
 
48
- The plugin is version `0.3.0` from its own `package.json`.
48
+ The package version is `@yiln-dsh/dsh-plugin-file-explorer@0.4.1`.
49
49
 
50
50
  ### Local source directory
51
51
 
@@ -61,18 +61,18 @@ pnpm pack
61
61
  ```
62
62
 
63
63
  ```bash
64
- dsh plugin --profile web add ./dsh-plugin-file-explorer-0.2.0.tgz
64
+ dsh plugin --profile web add ./yiln-dsh-dsh-plugin-file-explorer-0.4.1.tgz
65
65
  ```
66
66
 
67
67
  ### npm package
68
68
 
69
69
  ```bash
70
70
  cd /path/to/dsh-plugin-file-explorer
71
- npm publish
71
+ npm publish --access public
72
72
  ```
73
73
 
74
74
  ```bash
75
- dsh plugin --profile web add dsh-plugin-file-explorer
75
+ dsh plugin --profile web add @yiln-dsh/dsh-plugin-file-explorer@latest
76
76
  ```
77
77
 
78
78
  The profile must be the `web` profile. The host row serves the API routes and
@@ -83,8 +83,9 @@ apply the new profile composition.
83
83
 
84
84
  - The Host falls back to `sandboxPolicy.workspaceRoot` when no path is passed,
85
85
  and returns `{ ok, path, parent, entries }` JSON — never live objects.
86
- - `read` previews up to 256 KiB of UTF-8 text; `download` returns a base64
87
- data URL (64 MiB cap) that the browser triggers with `<a download>`.
86
+ - `read` previews up to 1 MiB of UTF-8 text or 16 MiB of recognized images;
87
+ image previews return a data URL for inline rendering. `download` returns a
88
+ base64 data URL (64 MiB cap) that the browser triggers with `<a download>`.
88
89
  - `delete` removes regular files (`rm -f`) and directories recursively
89
90
  (`rm -rf`); both are called only after a second-click confirm in the UI.
90
91
  - Git routes are read-only. They resolve the repository root with
package/client.js CHANGED
@@ -298,6 +298,14 @@ window.__ModuleLoader__.load({
298
298
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
299
299
  overflow: hidden;
300
300
  }
301
+ .dsh-fe-modal-fullscreen {
302
+ width: 100vw;
303
+ height: 100vh;
304
+ max-width: none;
305
+ max-height: none;
306
+ border: 0;
307
+ border-radius: 0;
308
+ }
301
309
  .dsh-fe-modal-head {
302
310
  display: flex;
303
311
  align-items: center;
@@ -319,6 +327,29 @@ window.__ModuleLoader__.load({
319
327
  color: var(--dsw-alias-label-secondary, #6b7280);
320
328
  font-size: 12px;
321
329
  }
330
+ .dsh-fe-image-tools {
331
+ flex: 0 0 auto;
332
+ display: inline-flex;
333
+ align-items: center;
334
+ gap: 2px;
335
+ }
336
+ .dsh-fe-image-tool {
337
+ width: 28px;
338
+ height: 28px;
339
+ padding: 0;
340
+ }
341
+ .dsh-fe-image-tool:disabled {
342
+ opacity: 0.35;
343
+ cursor: default;
344
+ }
345
+ .dsh-fe-image-zoom-label {
346
+ display: inline-block;
347
+ min-width: 40px;
348
+ color: var(--dsw-alias-label-secondary, #6b7280);
349
+ font-size: 11px;
350
+ text-align: center;
351
+ white-space: nowrap;
352
+ }
322
353
  .dsh-fe-modal-body {
323
354
  flex: 1 1 auto;
324
355
  min-height: 0;
@@ -331,6 +362,47 @@ window.__ModuleLoader__.load({
331
362
  font-size: 12px;
332
363
  line-height: 18px;
333
364
  }
365
+ .dsh-fe-modal-body-image {
366
+ display: flex;
367
+ align-items: stretch;
368
+ justify-content: stretch;
369
+ min-height: 160px;
370
+ padding: 0;
371
+ overflow: hidden;
372
+ background: var(--dsw-alias-bg-base, #f5f5f4);
373
+ white-space: normal;
374
+ }
375
+ .dsh-fe-image-stage {
376
+ flex: 1 1 auto;
377
+ min-width: 0;
378
+ min-height: 0;
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: center;
382
+ overflow: hidden;
383
+ touch-action: none;
384
+ }
385
+ .dsh-fe-preview-image {
386
+ display: block;
387
+ max-width: 100%;
388
+ max-height: 60vh;
389
+ width: auto;
390
+ height: auto;
391
+ object-fit: contain;
392
+ user-select: none;
393
+ -webkit-user-drag: none;
394
+ transition: transform 120ms ease;
395
+ }
396
+ .dsh-fe-modal-fullscreen .dsh-fe-preview-image {
397
+ max-height: calc(100vh - 64px);
398
+ }
399
+ .dsh-fe-preview-image-pannable {
400
+ cursor: grab;
401
+ }
402
+ .dsh-fe-preview-image-dragging {
403
+ cursor: grabbing;
404
+ transition: none;
405
+ }
334
406
  .dsh-git-bar {
335
407
  display: flex;
336
408
  align-items: center;
@@ -726,6 +798,29 @@ window.__ModuleLoader__.load({
726
798
  React.createElement('line', { x1: '18', y1: '6', x2: '6', y2: '18' }),
727
799
  React.createElement('line', { x1: '6', y1: '6', x2: '18', y2: '18' }),
728
800
  )
801
+ const zoomOutIcon = React.createElement(React.Fragment, null,
802
+ React.createElement('circle', { cx: '10.5', cy: '10.5', r: '6.5' }),
803
+ React.createElement('line', { x1: '15.5', y1: '15.5', x2: '21', y2: '21' }),
804
+ React.createElement('line', { x1: '8', y1: '10.5', x2: '13', y2: '10.5' }),
805
+ )
806
+ const zoomInIcon = React.createElement(React.Fragment, null,
807
+ React.createElement('circle', { cx: '10.5', cy: '10.5', r: '6.5' }),
808
+ React.createElement('line', { x1: '15.5', y1: '15.5', x2: '21', y2: '21' }),
809
+ React.createElement('line', { x1: '8', y1: '10.5', x2: '13', y2: '10.5' }),
810
+ React.createElement('line', { x1: '10.5', y1: '8', x2: '10.5', y2: '13' }),
811
+ )
812
+ const fullscreenIcon = React.createElement(React.Fragment, null,
813
+ React.createElement('polyline', { points: '8 3 3 3 3 8' }),
814
+ React.createElement('polyline', { points: '16 3 21 3 21 8' }),
815
+ React.createElement('polyline', { points: '8 21 3 21 3 16' }),
816
+ React.createElement('polyline', { points: '16 21 21 21 21 16' }),
817
+ )
818
+ const fullscreenExitIcon = React.createElement(React.Fragment, null,
819
+ React.createElement('polyline', { points: '9 3 9 9 3 9' }),
820
+ React.createElement('polyline', { points: '15 3 15 9 21 9' }),
821
+ React.createElement('polyline', { points: '9 21 9 15 3 15' }),
822
+ React.createElement('polyline', { points: '15 21 15 15 21 15' }),
823
+ )
729
824
  // Mirror of DeepSeek's sidebar IconPanelLeftOutline16 (spine on the
730
825
  // right): this panel lives on the right edge, so the glyph mirrors the
731
826
  // left sidebar's, signaling collapse into the right-edge icon rail.
@@ -1263,6 +1358,11 @@ window.__ModuleLoader__.load({
1263
1358
  const [draftPath, setDraftPath] = React.useState('')
1264
1359
  const [preview, setPreview] = React.useState(null)
1265
1360
  const [previewLoading, setPreviewLoading] = React.useState(false)
1361
+ const [imageScale, setImageScale] = React.useState(1)
1362
+ const [imageOffset, setImageOffset] = React.useState({ x: 0, y: 0 })
1363
+ const [imageFullscreen, setImageFullscreen] = React.useState(false)
1364
+ const [imageDragging, setImageDragging] = React.useState(false)
1365
+ const imageDrag = React.useRef(null)
1266
1366
  const [pendingDelete, setPendingDelete] = React.useState(null)
1267
1367
  const [view, setView] = React.useState('files')
1268
1368
  const [collapsed, setCollapsed] = React.useState(false)
@@ -1341,7 +1441,7 @@ window.__ModuleLoader__.load({
1341
1441
  const collapse = () => {
1342
1442
  setCollapsed(true)
1343
1443
  railActive = true
1344
- setPreview(null)
1444
+ closePreview()
1345
1445
  setPendingDelete(null)
1346
1446
  setEditingPath(false)
1347
1447
  if (layout !== undefined) {
@@ -1373,8 +1473,67 @@ window.__ModuleLoader__.load({
1373
1473
  setRequestPath(next)
1374
1474
  }
1375
1475
 
1476
+ const resetImageZoom = () => {
1477
+ imageDrag.current = null
1478
+ setImageDragging(false)
1479
+ setImageScale(1)
1480
+ setImageOffset({ x: 0, y: 0 })
1481
+ }
1482
+ const resetImageView = () => {
1483
+ resetImageZoom()
1484
+ setImageFullscreen(false)
1485
+ }
1486
+ const closePreview = () => {
1487
+ resetImageView()
1488
+ setPreview(null)
1489
+ }
1490
+ const setImageZoom = (value) => {
1491
+ const next = Math.max(0.25, Math.min(4, Math.round(value * 4) / 4))
1492
+ setImageScale(next)
1493
+ if (next <= 1) setImageOffset({ x: 0, y: 0 })
1494
+ }
1495
+ const changeImageZoom = (delta) => setImageZoom(imageScale + delta)
1496
+ const handleImageWheel = (event) => {
1497
+ event.preventDefault()
1498
+ changeImageZoom(event.deltaY < 0 ? 0.25 : -0.25)
1499
+ }
1500
+ const startImageDrag = (event) => {
1501
+ if (imageScale <= 1 || event.button !== 0) return
1502
+ imageDrag.current = {
1503
+ pointerId: event.pointerId,
1504
+ startX: event.clientX,
1505
+ startY: event.clientY,
1506
+ originX: imageOffset.x,
1507
+ originY: imageOffset.y,
1508
+ }
1509
+ try { event.currentTarget.setPointerCapture(event.pointerId) } catch (_e) { }
1510
+ setImageDragging(true)
1511
+ }
1512
+ const moveImageDrag = (event) => {
1513
+ const drag = imageDrag.current
1514
+ if (!drag || drag.pointerId !== event.pointerId) return
1515
+ setImageOffset({
1516
+ x: drag.originX + event.clientX - drag.startX,
1517
+ y: drag.originY + event.clientY - drag.startY,
1518
+ })
1519
+ }
1520
+ const endImageDrag = (event) => {
1521
+ const drag = imageDrag.current
1522
+ if (!drag || drag.pointerId !== event.pointerId) return
1523
+ imageDrag.current = null
1524
+ setImageDragging(false)
1525
+ try { event.currentTarget.releasePointerCapture(event.pointerId) } catch (_e) { }
1526
+ }
1527
+ const handlePreviewKeyDown = (event) => {
1528
+ if (event.key !== 'Escape') return
1529
+ event.stopPropagation()
1530
+ if (imageFullscreen) setImageFullscreen(false)
1531
+ else closePreview()
1532
+ }
1533
+
1376
1534
  const openPreview = (entry) => {
1377
1535
  setPendingDelete(null)
1536
+ resetImageView()
1378
1537
  setPreviewLoading(true)
1379
1538
  setPreview(null)
1380
1539
  api('read', { path: entry.path })
@@ -1582,30 +1741,94 @@ window.__ModuleLoader__.load({
1582
1741
  preview &&
1583
1742
  React.createElement('div', {
1584
1743
  className: 'dsh-fe-overlay',
1585
- onClick: () => setPreview(null),
1744
+ onClick: closePreview,
1586
1745
  },
1587
1746
  React.createElement('div', {
1588
- className: 'dsh-fe-modal',
1747
+ className: preview.kind === 'image' && imageFullscreen ? 'dsh-fe-modal dsh-fe-modal-fullscreen' : 'dsh-fe-modal',
1589
1748
  role: 'dialog',
1590
1749
  'aria-label': 'File preview',
1750
+ 'aria-modal': true,
1751
+ tabIndex: -1,
1752
+ onKeyDown: handlePreviewKeyDown,
1591
1753
  onClick: (event) => event.stopPropagation(),
1592
1754
  },
1593
1755
  React.createElement('div', { className: 'dsh-fe-modal-head' },
1594
1756
  React.createElement('div', { className: 'dsh-fe-modal-title' }, preview.name || 'Preview'),
1595
- React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.binary ? 'Binary' : formatSize(preview.size)),
1757
+ React.createElement('div', { className: 'dsh-fe-modal-meta' }, preview.kind === 'image' ? `Image / ${formatSize(preview.size)}` : preview.binary ? 'Binary' : formatSize(preview.size)),
1758
+ preview.kind === 'image' && preview.dataUrl
1759
+ ? React.createElement('div', {
1760
+ className: 'dsh-fe-image-tools',
1761
+ role: 'toolbar',
1762
+ 'aria-label': 'Image controls',
1763
+ },
1764
+ React.createElement('button', {
1765
+ type: 'button',
1766
+ className: 'dsh-fe-icon dsh-fe-image-tool',
1767
+ disabled: imageScale <= 0.25,
1768
+ onClick: () => changeImageZoom(-0.25),
1769
+ 'aria-label': 'Zoom out',
1770
+ title: 'Zoom out',
1771
+ }, svgIcon(zoomOutIcon, 14)),
1772
+ React.createElement('button', {
1773
+ type: 'button',
1774
+ className: 'dsh-fe-icon dsh-fe-image-tool',
1775
+ onClick: resetImageZoom,
1776
+ 'aria-label': 'Reset image zoom',
1777
+ title: 'Reset zoom',
1778
+ }, React.createElement('span', { className: 'dsh-fe-image-zoom-label' }, `${Math.round(imageScale * 100)}%`)),
1779
+ React.createElement('button', {
1780
+ type: 'button',
1781
+ className: 'dsh-fe-icon dsh-fe-image-tool',
1782
+ disabled: imageScale >= 4,
1783
+ onClick: () => changeImageZoom(0.25),
1784
+ 'aria-label': 'Zoom in',
1785
+ title: 'Zoom in',
1786
+ }, svgIcon(zoomInIcon, 14)),
1787
+ React.createElement('button', {
1788
+ type: 'button',
1789
+ className: 'dsh-fe-icon dsh-fe-image-tool',
1790
+ onClick: () => setImageFullscreen((value) => !value),
1791
+ 'aria-label': imageFullscreen ? 'Exit fullscreen' : 'Enter fullscreen',
1792
+ title: imageFullscreen ? 'Exit fullscreen' : 'Fullscreen',
1793
+ }, svgIcon(imageFullscreen ? fullscreenExitIcon : fullscreenIcon, 14)),
1794
+ )
1795
+ : null,
1596
1796
  React.createElement('button', {
1597
1797
  type: 'button',
1598
1798
  className: 'dsh-fe-icon',
1599
- onClick: () => setPreview(null),
1799
+ onClick: closePreview,
1600
1800
  'aria-label': 'Close preview',
1601
1801
  }, svgIcon(closeIcon, 14)),
1602
1802
  ),
1603
- React.createElement('div', { className: 'dsh-fe-modal-body' },
1803
+ React.createElement('div', {
1804
+ className: preview.kind === 'image' ? 'dsh-fe-modal-body dsh-fe-modal-body-image' : 'dsh-fe-modal-body',
1805
+ onWheel: preview.kind === 'image' && preview.dataUrl ? handleImageWheel : undefined,
1806
+ onPointerDown: preview.kind === 'image' && preview.dataUrl ? startImageDrag : undefined,
1807
+ onPointerMove: preview.kind === 'image' && preview.dataUrl ? moveImageDrag : undefined,
1808
+ onPointerUp: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1809
+ onPointerCancel: preview.kind === 'image' && preview.dataUrl ? endImageDrag : undefined,
1810
+ },
1604
1811
  previewLoading
1605
1812
  ? 'Loading...'
1606
1813
  : preview.error
1607
1814
  ? preview.error
1608
- : preview.text !== undefined ? preview.text : preview.tooLarge ? 'File is too large to preview here' : '(No preview)',
1815
+ : preview.kind === 'image'
1816
+ ? preview.dataUrl
1817
+ ? React.createElement('div', { className: 'dsh-fe-image-stage' },
1818
+ React.createElement('img', {
1819
+ className: 'dsh-fe-preview-image' + (imageScale > 1 ? ' dsh-fe-preview-image-pannable' : '') + (imageDragging ? ' dsh-fe-preview-image-dragging' : ''),
1820
+ style: { transform: `translate3d(${imageOffset.x}px, ${imageOffset.y}px, 0) scale(${imageScale})` },
1821
+ src: preview.dataUrl,
1822
+ alt: preview.name || 'Image preview',
1823
+ draggable: false,
1824
+ })
1825
+ )
1826
+ : preview.tooLarge ? 'Image is too large to preview here' : '(No preview)'
1827
+ : preview.text !== undefined && preview.text !== null
1828
+ ? preview.text
1829
+ : preview.tooLarge
1830
+ ? 'File is too large to preview here'
1831
+ : preview.binary ? 'Binary file cannot be previewed' : '(No preview)',
1609
1832
  ),
1610
1833
  ),
1611
1834
  ),
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * dsh-plugin-file-explorer — Host half (static DSH bundle).
3
3
  *
4
4
  * Registers exact HTTP routes under /_dsh/file-explorer for the browser
5
- * bundle: list (with parent path), read (text preview), download (data URL),
5
+ * bundle: list (with parent path), read (text/image preview), download (data URL),
6
6
  * and delete.
7
7
  */
8
8
 
@@ -145,12 +145,32 @@ export function apply(ctx) {
145
145
  const target = await fs.resolve(req.path)
146
146
  const info = await fs.stat(target)
147
147
  const size = info && typeof info.size === 'number' ? info.size : null
148
- const limit = 256 * 1024
148
+ const name = target.displayPath.split('/').pop()
149
+ const mime = guessMime(name)
150
+ const isImage = mime.startsWith('image/')
151
+ const limit = isImage ? 16 * 1024 * 1024 : 1024 * 1024
149
152
  if (size !== null && size > limit) {
150
- sendJson(res, 200, { ok: true, name: target.displayPath.split('/').pop(), size, tooLarge: true })
153
+ sendJson(res, 200, {
154
+ ok: true,
155
+ name,
156
+ size,
157
+ tooLarge: true,
158
+ ...(isImage ? { kind: 'image', mime } : {}),
159
+ })
151
160
  return
152
161
  }
153
162
  const bytes = await fs.readBytes(target, undefined, limit)
163
+ if (isImage) {
164
+ sendJson(res, 200, {
165
+ ok: true,
166
+ name,
167
+ size,
168
+ kind: 'image',
169
+ mime,
170
+ dataUrl: `data:${mime};base64,${bytesToBase64(bytes)}`,
171
+ })
172
+ return
173
+ }
154
174
  const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
155
175
  let binary = false
156
176
  for (let i = 0; i < bytes.length; i += 1) {
@@ -159,7 +179,7 @@ export function apply(ctx) {
159
179
  if (!binary && text.replace(/\uFFFD/g, '').length * 10 < text.length * 9) binary = true
160
180
  sendJson(res, 200, {
161
181
  ok: true,
162
- name: target.displayPath.split('/').pop(),
182
+ name,
163
183
  size,
164
184
  text: binary ? null : text,
165
185
  binary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yiln-dsh/dsh-plugin-file-explorer",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },