@aryacode/dsh-aryacode-polish 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.18.0] - 2026-10-28
4
+
5
+ ### Added
6
+ - **Folders that exist before their content.** The workspace menu can declare a folder, an
7
+ empty folder draws its own header with an empty count, accepts a workspace drop, and
8
+ can be removed again. Declared folders are remembered (ryacode-polish:folders.v1).
9
+
10
+
11
+ ## [0.17.0] - 2026-10-28
12
+
13
+ ### Added
14
+ - **Drop a workspace on a folder header to file it there.** The title is rewritten to
15
+ Folder/Name (any previous prefix is replaced, not stacked) through the Host rename.
16
+ - **A workspace note shows as a card on hover**, not just as a tooltip title.
17
+
18
+
3
19
  ## [0.16.0] - 2026-10-28
4
20
 
5
21
  ### Added
@@ -386,6 +386,68 @@ const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
386
386
  const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
387
387
  const FOLD_KEY = 'aryacode-polish:folded.v1'
388
388
  const DENSITY_KEY = 'aryacode-polish:density.v1'
389
+ const FOLDER_KEY = 'aryacode-polish:folders.v1'
390
+
391
+ const cleanFolder = (name) => {
392
+ const value = String(name || '').trim().replace(/^\/+|\/+$/g, '')
393
+ return value && !value.includes('/') ? value : undefined
394
+ }
395
+
396
+ /** Add a folder name, refusing empties, duplicates and anything nested. */
397
+ export function addFolder(list, name) {
398
+ const folder = cleanFolder(name)
399
+ const names = Array.isArray(list) ? list.slice() : []
400
+ if (!folder || names.includes(folder)) return names
401
+ names.push(folder)
402
+ return names
403
+ }
404
+
405
+ /** Remove a folder name from the declared list. */
406
+ export function removeFolder(list, name) {
407
+ const folder = cleanFolder(name)
408
+ const names = Array.isArray(list) ? list : []
409
+ return folder ? names.filter((entry) => entry !== folder) : names.slice()
410
+ }
411
+
412
+ export function readFolders(storage) {
413
+ try {
414
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
415
+ if (!store) return []
416
+ const parsed = JSON.parse(store.getItem(FOLDER_KEY) || '[]')
417
+ if (!Array.isArray(parsed)) return []
418
+ return parsed.map(cleanFolder).filter(Boolean)
419
+ } catch {
420
+ return []
421
+ }
422
+ }
423
+
424
+ export function writeFolders(list, storage) {
425
+ try {
426
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
427
+ if (!store) return
428
+ store.setItem(FOLDER_KEY, JSON.stringify((Array.isArray(list) ? list : []).map(cleanFolder).filter(Boolean)))
429
+ } catch {
430
+ /* folders are a convenience, not a requirement */
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Folder names the tree must draw: the ones its workspaces imply, then any declared but
436
+ * still empty. Declared folders keep their order so an empty folder does not jump around
437
+ * when the first workspace lands in it.
438
+ */
439
+ export function folderNamesOf(rows, declared) {
440
+ const seen = []
441
+ for (const row of rows || []) {
442
+ const name = folderKey(row.workspace)
443
+ if (name && !seen.includes(name)) seen.push(name)
444
+ }
445
+ for (const name of declared || []) {
446
+ const folder = cleanFolder(name)
447
+ if (folder && !seen.includes(folder)) seen.push(folder)
448
+ }
449
+ return seen
450
+ }
389
451
 
390
452
  export const DENSITIES = ['comfortable', 'compact']
391
453
 
@@ -475,6 +537,22 @@ const WS_MENU_HINT = {
475
537
  color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
476
538
  }
477
539
 
540
+ const WS_NOTE_CARD = {
541
+ position: 'fixed',
542
+ zIndex: 1250,
543
+ maxWidth: '260px',
544
+ padding: '6px 9px',
545
+ borderRadius: 'var(--dsw-radius-md, 8px)',
546
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))',
547
+ background: 'Canvas',
548
+ color: 'var(--dsw-alias-label-secondary, #61666b)',
549
+ fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
550
+ lineHeight: 1.35,
551
+ boxShadow: 'var(--dsw-elevation-prominent, 0 2px 8px rgba(0, 0, 0, 0.08))',
552
+ pointerEvents: 'none',
553
+ whiteSpace: 'pre-wrap',
554
+ }
555
+
478
556
  const WS_GLYPH = {
479
557
  flex: 'none',
480
558
  width: '16px',
@@ -616,6 +694,19 @@ const SWITCHER_HINT = {
616
694
  * written to the Host and renaming a workspace is enough to move it between
617
695
  * folders — the same idea as the reference plugin, without its "/" toggle.
618
696
  */
697
+ /**
698
+ * The title a workspace needs to sit under `folder`: its own name moved behind the
699
+ * folder prefix, with any previous prefix replaced rather than stacked.
700
+ */
701
+ export function fileUnder(label, folder) {
702
+ const name = String(label || '').trim()
703
+ const target = String(folder || '').trim()
704
+ if (!name || !target) return name
705
+ const cut = name.lastIndexOf('/')
706
+ const bare = (cut >= 0 ? name.slice(cut + 1) : name).trim()
707
+ return bare ? target + '/' + bare : target
708
+ }
709
+
619
710
  export function folderKey(workspace) {
620
711
  const label = workspaceLabel(workspace)
621
712
  const cut = label.lastIndexOf('/')
@@ -833,12 +924,16 @@ export function BetterWorkspace(props) {
833
924
  const [dropping, setDropping] = React.useState(null)
834
925
  const [look, setLook] = React.useState(() => readAppearance())
835
926
  const [noteDraft, setNoteDraft] = React.useState('')
927
+ const [card, setCard] = React.useState(null)
836
928
  const folded = React.useMemo(() => readFolded(), [])
837
929
  const [folders, setFolders] = React.useState(folded.folders)
930
+ const [declared, setDeclared] = React.useState(() => readFolders())
931
+ const [folderDraft, setFolderDraft] = React.useState('')
838
932
  const [switcher, setSwitcher] = React.useState(null)
839
933
  const [density, setDensity] = React.useState(() => readDensity())
840
934
  const [archiveOpen, setArchiveOpen] = React.useState(false)
841
935
  React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
936
+ React.useEffect(() => { writeFolders(declared) }, [declared])
842
937
  const [renaming, setRenaming] = React.useState(null)
843
938
  const commitRename = async () => {
844
939
  const draft = renaming
@@ -890,7 +985,7 @@ export function BetterWorkspace(props) {
890
985
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
891
986
  const totalCount = allRows.reduce((sum, row) => sum + row.sessions.length, 0)
892
987
  const shownCount = rows.reduce((sum, row) => sum + row.sessions.length, 0)
893
- const folderNames = Array.from(new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean)))
988
+ const folderNames = folderNamesOf(rows, declared)
894
989
  // One click should always do the useful thing: if anything is folded, the button
895
990
  // expands everything (folders included); only when nothing is folded does it fold.
896
991
  const anyFolded = rows.some(row => expanded[row.workspace.workspaceId] === false)
@@ -1094,6 +1189,17 @@ export function BetterWorkspace(props) {
1094
1189
  }
1095
1190
  },
1096
1191
  }, 'Restore saved order'),
1192
+ React.createElement('button', {
1193
+ type: 'button',
1194
+ 'data-polish-folder-new': 'open',
1195
+ style: WS_MENU_ITEM,
1196
+ onClick: () => {
1197
+ const anchor = menu.anchor
1198
+ closeMenu()
1199
+ setFolderDraft('')
1200
+ setMenu({ kind: 'folder', anchor })
1201
+ },
1202
+ }, 'New folder…'),
1097
1203
  React.createElement('button', {
1098
1204
  type: 'button',
1099
1205
  'data-polish-glyph': 'open',
@@ -1161,6 +1267,30 @@ export function BetterWorkspace(props) {
1161
1267
  }, 'Unarchive session'))
1162
1268
  : null
1163
1269
 
1270
+ const folderMenu = menu && menu.kind === 'folder'
1271
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1272
+ React.createElement('input', {
1273
+ 'data-polish-folder-input': 'true',
1274
+ type: 'text',
1275
+ value: folderDraft,
1276
+ placeholder: 'Folder name',
1277
+ autoFocus: true,
1278
+ style: WS_NOTE_INPUT,
1279
+ onChange: (event) => setFolderDraft(event.target.value),
1280
+ onKeyDown: (event) => {
1281
+ if (event.key === 'Escape') { event.stopPropagation(); closeMenu(); return }
1282
+ if (event.key !== 'Enter') return
1283
+ const next = addFolder(declared, folderDraft)
1284
+ closeMenu()
1285
+ if (next.length === declared.length) { setNotice('That folder already exists.'); return }
1286
+ setDeclared(next)
1287
+ setFolders((prev) => Object.assign({}, prev, { [next[next.length - 1]]: false }))
1288
+ setNotice('Folder added.')
1289
+ },
1290
+ }),
1291
+ React.createElement('div', { style: WS_MENU_HINT }, 'Enter creates, Escape cancels'))
1292
+ : null
1293
+
1164
1294
  const glyphMenu = menu && menu.kind === 'glyph'
1165
1295
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1166
1296
  React.createElement('div', { style: WS_GLYPH_GRID },
@@ -1215,6 +1345,13 @@ export function BetterWorkspace(props) {
1215
1345
  React.createElement('div', { style: WS_MENU_HINT }, 'Enter saves, Escape cancels'))
1216
1346
  : null
1217
1347
 
1348
+ const noteCard = card
1349
+ ? ReactDOM.createPortal(React.createElement('div', {
1350
+ 'data-polish-note-card': 'true',
1351
+ style: Object.assign({}, WS_NOTE_CARD, { top: card.top, left: card.left }),
1352
+ }, card.text), document.body)
1353
+ : null
1354
+
1218
1355
  const paintMenu = menu && menu.kind === 'paint'
1219
1356
  ? (() => {
1220
1357
  const workspace = menu.workspace
@@ -1469,6 +1606,7 @@ export function BetterWorkspace(props) {
1469
1606
  const folded = folder !== undefined && folders[folder] === true
1470
1607
 
1471
1608
  if (folder !== undefined && !announced[folder]) {
1609
+ /* handled below for both filled and empty folders */
1472
1610
  announced[folder] = true
1473
1611
  const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1474
1612
  children.push(React.createElement('div', {
@@ -1477,8 +1615,31 @@ export function BetterWorkspace(props) {
1477
1615
  role: 'group',
1478
1616
  'aria-label': folder,
1479
1617
  title: folded ? 'Expand ' + folder : 'Collapse ' + folder,
1480
- style: WS_FOLDER_ROW,
1618
+ style: Object.assign({}, WS_FOLDER_ROW, {
1619
+ background: dropping === 'folder:' + folder
1620
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1621
+ : undefined,
1622
+ }),
1481
1623
  onClick: () => setFolders((prev) => Object.assign({}, prev, { [folder]: prev[folder] !== true })),
1624
+ onDragOver: (event) => {
1625
+ if (!drag || drag.kind !== 'workspace') return
1626
+ if (folderOf(rows.find((entry) => entry.workspace.workspaceId === drag.id)?.workspace) === folder) return
1627
+ event.preventDefault()
1628
+ setDropping('folder:' + folder)
1629
+ },
1630
+ onDragLeave: () => setDropping(null),
1631
+ onDrop: (event) => {
1632
+ event.preventDefault()
1633
+ setDropping(null)
1634
+ if (!drag || drag.kind !== 'workspace') return
1635
+ const moving = rows.find((entry) => entry.workspace.workspaceId === drag.id)
1636
+ if (!moving || folderOf(moving.workspace) === folder) return
1637
+ const title = fileUnder(workspaceLabel(moving.workspace), folder)
1638
+ Promise.resolve()
1639
+ .then(() => services.renameWorkspace(moving.workspace.workspaceId, title))
1640
+ .then(() => setNotice('Filed under ' + folder + '.'))
1641
+ .catch(() => setNotice('Could not file that workspace.'))
1642
+ },
1482
1643
  },
1483
1644
  React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1484
1645
  React.createElement('span', { style: WS_TITLE }, folder),
@@ -1512,8 +1673,14 @@ export function BetterWorkspace(props) {
1512
1673
  onClick: () => toggleWorkspace(workspace.workspaceId),
1513
1674
  onContextMenu: (event) => openWorkspaceMenu(event, workspace),
1514
1675
  title: looks.note || undefined,
1515
- onMouseEnter: () => setHover('workspace:' + workspace.workspaceId),
1516
- onMouseLeave: () => setHover(null),
1676
+ onMouseEnter: (event) => {
1677
+ setHover('workspace:' + workspace.workspaceId)
1678
+ const note = appearanceOf(look, workspace.workspaceId).note
1679
+ if (!note) return
1680
+ const box = event.currentTarget.getBoundingClientRect()
1681
+ setCard({ text: note, top: box.bottom + 6, left: Math.max(8, box.left) })
1682
+ },
1683
+ onMouseLeave: () => { setHover(null); setCard(null) },
1517
1684
  onDragStart: () => setDrag({ kind: 'workspace', id: workspace.workspaceId }),
1518
1685
  onDragOver: (event) => {
1519
1686
  if (!drag || drag.kind !== 'workspace' || drag.id === workspace.workspaceId) return
@@ -1552,6 +1719,58 @@ export function BetterWorkspace(props) {
1552
1719
  }
1553
1720
  })
1554
1721
 
1722
+ const filled = new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean))
1723
+ for (const folder of folderNames) {
1724
+ if (filled.has(folder)) continue
1725
+ const empty = folders[folder] === true
1726
+ children.push(React.createElement('div', {
1727
+ key: 'empty-folder:' + folder,
1728
+ 'data-polish-ws-empty': folder,
1729
+ role: 'group',
1730
+ 'aria-label': folder,
1731
+ title: 'Drop a workspace here to file it in ' + folder,
1732
+ style: Object.assign({}, WS_FOLDER_ROW, {
1733
+ background: dropping === 'folder:' + folder
1734
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1735
+ : undefined,
1736
+ }),
1737
+ onDragOver: (event) => {
1738
+ if (!drag || drag.kind !== 'workspace') return
1739
+ event.preventDefault()
1740
+ setDropping('folder:' + folder)
1741
+ },
1742
+ onDragLeave: () => setDropping(null),
1743
+ onDrop: (event) => {
1744
+ event.preventDefault()
1745
+ setDropping(null)
1746
+ if (!drag || drag.kind !== 'workspace') return
1747
+ const moving = rows.find((entry) => entry.workspace.workspaceId === drag.id)
1748
+ if (!moving) return
1749
+ const title = fileUnder(workspaceLabel(moving.workspace), folder)
1750
+ Promise.resolve()
1751
+ .then(() => services.renameWorkspace(moving.workspace.workspaceId, title))
1752
+ .then(() => setNotice('Filed under ' + folder + '.'))
1753
+ .catch(() => setNotice('Could not file that workspace.'))
1754
+ },
1755
+ },
1756
+ React.createElement('span', { 'aria-hidden': 'true' }, '⌄'),
1757
+ React.createElement('span', { style: WS_TITLE }, folder),
1758
+ React.createElement('span', { style: WS_COUNT_NOTE }, 'empty'),
1759
+ React.createElement('button', {
1760
+ type: 'button',
1761
+ 'data-polish-folder-remove': folder,
1762
+ 'aria-label': 'Remove the empty folder ' + folder,
1763
+ title: 'Remove this empty folder',
1764
+ style: WS_ROW_ACTION,
1765
+ onClick: (event) => {
1766
+ event.stopPropagation()
1767
+ setDeclared(removeFolder(declared, folder))
1768
+ setNotice('Folder removed.')
1769
+ },
1770
+ }, '×')))
1771
+ if (!empty) continue
1772
+ }
1773
+
1555
1774
  if (archivedSessions.length > 0) {
1556
1775
  children.push(React.createElement('div', {
1557
1776
  key: 'archived-head',
@@ -1609,6 +1828,8 @@ export function BetterWorkspace(props) {
1609
1828
  sessionMenu,
1610
1829
  glyphMenu,
1611
1830
  noteMenu,
1831
+ folderMenu,
1832
+ noteCard,
1612
1833
  archivedMenu,
1613
1834
  paintMenu,
1614
1835
  workspaceMenu)
@@ -186,3 +186,27 @@ and the note surfaces as the row's tooltip, so it costs no vertical space in the
186
186
 
187
187
  Glyph validation is deliberately narrow (one to four characters): the field is a badge in a
188
188
  16 px slot, not a place to paste a sentence.
189
+
190
+ ## Filing by drag
191
+
192
+ Dropping a workspace on a folder header files it there: the title is rewritten to
193
+ `Folder/Name` through the Host's own rename, so the move is durable and the shipped
194
+ browser (when this switch is off) sees exactly the same structure. `fileUnder(label, folder)`
195
+ replaces any previous prefix instead of stacking it — dragging `A/B/Gamma` onto `C` gives
196
+ `C/Gamma`, not `C/A/B/Gamma` — and a workspace already inside the target folder is not a
197
+ drop target, so the gesture cannot rename something onto itself.
198
+
199
+ ## The note card
200
+
201
+ A workspace note shows as a small card under the row on hover rather than as a browser
202
+ tooltip: tooltips are slow to appear, unstyleable, and vanish when the pointer leaves. The
203
+ card is portalled, `pointer-events: none`, and only exists while a note does.
204
+
205
+ ## Folders without content
206
+
207
+ A folder can be declared before anything lives in it (**New folder…** in a workspace menu).
208
+ It draws its own header with the word `empty` where the count would be, accepts a workspace
209
+ drop like any other folder header, and carries a `×` that removes the declaration. Declared
210
+ names live in `aryacode-polish:folders.v1`; the tree draws the names its workspaces imply
211
+ first and the declared ones after, so an empty folder never jumps ahead of real content and
212
+ disappears on its own the moment the first workspace is filed into it.
package/lib/client.js CHANGED
@@ -53,7 +53,7 @@ window.__ModuleLoader__.load({
53
53
  ]
54
54
  const ROW_ID = "polish"
55
55
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
56
- const CLIENT_VERSION = "0.16.0"
56
+ const CLIENT_VERSION = "0.18.0"
57
57
 
58
58
  /* html2canvas 1.4.1 — MIT, vendored from vendor/html2canvas.min.js.
59
59
  * A UMD build, so it assigns itself to globalThis; face.js reads it lazily. */
@@ -587,6 +587,68 @@ window.__ModuleLoader__.load({
587
587
  const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
588
588
  const FOLD_KEY = 'aryacode-polish:folded.v1'
589
589
  const DENSITY_KEY = 'aryacode-polish:density.v1'
590
+ const FOLDER_KEY = 'aryacode-polish:folders.v1'
591
+
592
+ const cleanFolder = (name) => {
593
+ const value = String(name || '').trim().replace(/^\/+|\/+$/g, '')
594
+ return value && !value.includes('/') ? value : undefined
595
+ }
596
+
597
+ /** Add a folder name, refusing empties, duplicates and anything nested. */
598
+ function addFolder(list, name) {
599
+ const folder = cleanFolder(name)
600
+ const names = Array.isArray(list) ? list.slice() : []
601
+ if (!folder || names.includes(folder)) return names
602
+ names.push(folder)
603
+ return names
604
+ }
605
+
606
+ /** Remove a folder name from the declared list. */
607
+ function removeFolder(list, name) {
608
+ const folder = cleanFolder(name)
609
+ const names = Array.isArray(list) ? list : []
610
+ return folder ? names.filter((entry) => entry !== folder) : names.slice()
611
+ }
612
+
613
+ function readFolders(storage) {
614
+ try {
615
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
616
+ if (!store) return []
617
+ const parsed = JSON.parse(store.getItem(FOLDER_KEY) || '[]')
618
+ if (!Array.isArray(parsed)) return []
619
+ return parsed.map(cleanFolder).filter(Boolean)
620
+ } catch {
621
+ return []
622
+ }
623
+ }
624
+
625
+ function writeFolders(list, storage) {
626
+ try {
627
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
628
+ if (!store) return
629
+ store.setItem(FOLDER_KEY, JSON.stringify((Array.isArray(list) ? list : []).map(cleanFolder).filter(Boolean)))
630
+ } catch {
631
+ /* folders are a convenience, not a requirement */
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Folder names the tree must draw: the ones its workspaces imply, then any declared but
637
+ * still empty. Declared folders keep their order so an empty folder does not jump around
638
+ * when the first workspace lands in it.
639
+ */
640
+ function folderNamesOf(rows, declared) {
641
+ const seen = []
642
+ for (const row of rows || []) {
643
+ const name = folderKey(row.workspace)
644
+ if (name && !seen.includes(name)) seen.push(name)
645
+ }
646
+ for (const name of declared || []) {
647
+ const folder = cleanFolder(name)
648
+ if (folder && !seen.includes(folder)) seen.push(folder)
649
+ }
650
+ return seen
651
+ }
590
652
 
591
653
  const DENSITIES = ['comfortable', 'compact']
592
654
 
@@ -676,6 +738,22 @@ window.__ModuleLoader__.load({
676
738
  color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
677
739
  }
678
740
 
741
+ const WS_NOTE_CARD = {
742
+ position: 'fixed',
743
+ zIndex: 1250,
744
+ maxWidth: '260px',
745
+ padding: '6px 9px',
746
+ borderRadius: 'var(--dsw-radius-md, 8px)',
747
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))',
748
+ background: 'Canvas',
749
+ color: 'var(--dsw-alias-label-secondary, #61666b)',
750
+ fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
751
+ lineHeight: 1.35,
752
+ boxShadow: 'var(--dsw-elevation-prominent, 0 2px 8px rgba(0, 0, 0, 0.08))',
753
+ pointerEvents: 'none',
754
+ whiteSpace: 'pre-wrap',
755
+ }
756
+
679
757
  const WS_GLYPH = {
680
758
  flex: 'none',
681
759
  width: '16px',
@@ -817,6 +895,19 @@ window.__ModuleLoader__.load({
817
895
  * written to the Host and renaming a workspace is enough to move it between
818
896
  * folders — the same idea as the reference plugin, without its "/" toggle.
819
897
  */
898
+ /**
899
+ * The title a workspace needs to sit under `folder`: its own name moved behind the
900
+ * folder prefix, with any previous prefix replaced rather than stacked.
901
+ */
902
+ function fileUnder(label, folder) {
903
+ const name = String(label || '').trim()
904
+ const target = String(folder || '').trim()
905
+ if (!name || !target) return name
906
+ const cut = name.lastIndexOf('/')
907
+ const bare = (cut >= 0 ? name.slice(cut + 1) : name).trim()
908
+ return bare ? target + '/' + bare : target
909
+ }
910
+
820
911
  function folderKey(workspace) {
821
912
  const label = workspaceLabel(workspace)
822
913
  const cut = label.lastIndexOf('/')
@@ -1034,12 +1125,16 @@ window.__ModuleLoader__.load({
1034
1125
  const [dropping, setDropping] = React.useState(null)
1035
1126
  const [look, setLook] = React.useState(() => readAppearance())
1036
1127
  const [noteDraft, setNoteDraft] = React.useState('')
1128
+ const [card, setCard] = React.useState(null)
1037
1129
  const folded = React.useMemo(() => readFolded(), [])
1038
1130
  const [folders, setFolders] = React.useState(folded.folders)
1131
+ const [declared, setDeclared] = React.useState(() => readFolders())
1132
+ const [folderDraft, setFolderDraft] = React.useState('')
1039
1133
  const [switcher, setSwitcher] = React.useState(null)
1040
1134
  const [density, setDensity] = React.useState(() => readDensity())
1041
1135
  const [archiveOpen, setArchiveOpen] = React.useState(false)
1042
1136
  React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
1137
+ React.useEffect(() => { writeFolders(declared) }, [declared])
1043
1138
  const [renaming, setRenaming] = React.useState(null)
1044
1139
  const commitRename = async () => {
1045
1140
  const draft = renaming
@@ -1091,7 +1186,7 @@ window.__ModuleLoader__.load({
1091
1186
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
1092
1187
  const totalCount = allRows.reduce((sum, row) => sum + row.sessions.length, 0)
1093
1188
  const shownCount = rows.reduce((sum, row) => sum + row.sessions.length, 0)
1094
- const folderNames = Array.from(new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean)))
1189
+ const folderNames = folderNamesOf(rows, declared)
1095
1190
  // One click should always do the useful thing: if anything is folded, the button
1096
1191
  // expands everything (folders included); only when nothing is folded does it fold.
1097
1192
  const anyFolded = rows.some(row => expanded[row.workspace.workspaceId] === false)
@@ -1295,6 +1390,17 @@ window.__ModuleLoader__.load({
1295
1390
  }
1296
1391
  },
1297
1392
  }, 'Restore saved order'),
1393
+ React.createElement('button', {
1394
+ type: 'button',
1395
+ 'data-polish-folder-new': 'open',
1396
+ style: WS_MENU_ITEM,
1397
+ onClick: () => {
1398
+ const anchor = menu.anchor
1399
+ closeMenu()
1400
+ setFolderDraft('')
1401
+ setMenu({ kind: 'folder', anchor })
1402
+ },
1403
+ }, 'New folder…'),
1298
1404
  React.createElement('button', {
1299
1405
  type: 'button',
1300
1406
  'data-polish-glyph': 'open',
@@ -1362,6 +1468,30 @@ window.__ModuleLoader__.load({
1362
1468
  }, 'Unarchive session'))
1363
1469
  : null
1364
1470
 
1471
+ const folderMenu = menu && menu.kind === 'folder'
1472
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1473
+ React.createElement('input', {
1474
+ 'data-polish-folder-input': 'true',
1475
+ type: 'text',
1476
+ value: folderDraft,
1477
+ placeholder: 'Folder name',
1478
+ autoFocus: true,
1479
+ style: WS_NOTE_INPUT,
1480
+ onChange: (event) => setFolderDraft(event.target.value),
1481
+ onKeyDown: (event) => {
1482
+ if (event.key === 'Escape') { event.stopPropagation(); closeMenu(); return }
1483
+ if (event.key !== 'Enter') return
1484
+ const next = addFolder(declared, folderDraft)
1485
+ closeMenu()
1486
+ if (next.length === declared.length) { setNotice('That folder already exists.'); return }
1487
+ setDeclared(next)
1488
+ setFolders((prev) => Object.assign({}, prev, { [next[next.length - 1]]: false }))
1489
+ setNotice('Folder added.')
1490
+ },
1491
+ }),
1492
+ React.createElement('div', { style: WS_MENU_HINT }, 'Enter creates, Escape cancels'))
1493
+ : null
1494
+
1365
1495
  const glyphMenu = menu && menu.kind === 'glyph'
1366
1496
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1367
1497
  React.createElement('div', { style: WS_GLYPH_GRID },
@@ -1416,6 +1546,13 @@ window.__ModuleLoader__.load({
1416
1546
  React.createElement('div', { style: WS_MENU_HINT }, 'Enter saves, Escape cancels'))
1417
1547
  : null
1418
1548
 
1549
+ const noteCard = card
1550
+ ? ReactDOM.createPortal(React.createElement('div', {
1551
+ 'data-polish-note-card': 'true',
1552
+ style: Object.assign({}, WS_NOTE_CARD, { top: card.top, left: card.left }),
1553
+ }, card.text), document.body)
1554
+ : null
1555
+
1419
1556
  const paintMenu = menu && menu.kind === 'paint'
1420
1557
  ? (() => {
1421
1558
  const workspace = menu.workspace
@@ -1670,6 +1807,7 @@ window.__ModuleLoader__.load({
1670
1807
  const folded = folder !== undefined && folders[folder] === true
1671
1808
 
1672
1809
  if (folder !== undefined && !announced[folder]) {
1810
+ /* handled below for both filled and empty folders */
1673
1811
  announced[folder] = true
1674
1812
  const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1675
1813
  children.push(React.createElement('div', {
@@ -1678,8 +1816,31 @@ window.__ModuleLoader__.load({
1678
1816
  role: 'group',
1679
1817
  'aria-label': folder,
1680
1818
  title: folded ? 'Expand ' + folder : 'Collapse ' + folder,
1681
- style: WS_FOLDER_ROW,
1819
+ style: Object.assign({}, WS_FOLDER_ROW, {
1820
+ background: dropping === 'folder:' + folder
1821
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1822
+ : undefined,
1823
+ }),
1682
1824
  onClick: () => setFolders((prev) => Object.assign({}, prev, { [folder]: prev[folder] !== true })),
1825
+ onDragOver: (event) => {
1826
+ if (!drag || drag.kind !== 'workspace') return
1827
+ if (folderOf(rows.find((entry) => entry.workspace.workspaceId === drag.id)?.workspace) === folder) return
1828
+ event.preventDefault()
1829
+ setDropping('folder:' + folder)
1830
+ },
1831
+ onDragLeave: () => setDropping(null),
1832
+ onDrop: (event) => {
1833
+ event.preventDefault()
1834
+ setDropping(null)
1835
+ if (!drag || drag.kind !== 'workspace') return
1836
+ const moving = rows.find((entry) => entry.workspace.workspaceId === drag.id)
1837
+ if (!moving || folderOf(moving.workspace) === folder) return
1838
+ const title = fileUnder(workspaceLabel(moving.workspace), folder)
1839
+ Promise.resolve()
1840
+ .then(() => services.renameWorkspace(moving.workspace.workspaceId, title))
1841
+ .then(() => setNotice('Filed under ' + folder + '.'))
1842
+ .catch(() => setNotice('Could not file that workspace.'))
1843
+ },
1683
1844
  },
1684
1845
  React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1685
1846
  React.createElement('span', { style: WS_TITLE }, folder),
@@ -1713,8 +1874,14 @@ window.__ModuleLoader__.load({
1713
1874
  onClick: () => toggleWorkspace(workspace.workspaceId),
1714
1875
  onContextMenu: (event) => openWorkspaceMenu(event, workspace),
1715
1876
  title: looks.note || undefined,
1716
- onMouseEnter: () => setHover('workspace:' + workspace.workspaceId),
1717
- onMouseLeave: () => setHover(null),
1877
+ onMouseEnter: (event) => {
1878
+ setHover('workspace:' + workspace.workspaceId)
1879
+ const note = appearanceOf(look, workspace.workspaceId).note
1880
+ if (!note) return
1881
+ const box = event.currentTarget.getBoundingClientRect()
1882
+ setCard({ text: note, top: box.bottom + 6, left: Math.max(8, box.left) })
1883
+ },
1884
+ onMouseLeave: () => { setHover(null); setCard(null) },
1718
1885
  onDragStart: () => setDrag({ kind: 'workspace', id: workspace.workspaceId }),
1719
1886
  onDragOver: (event) => {
1720
1887
  if (!drag || drag.kind !== 'workspace' || drag.id === workspace.workspaceId) return
@@ -1753,6 +1920,58 @@ window.__ModuleLoader__.load({
1753
1920
  }
1754
1921
  })
1755
1922
 
1923
+ const filled = new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean))
1924
+ for (const folder of folderNames) {
1925
+ if (filled.has(folder)) continue
1926
+ const empty = folders[folder] === true
1927
+ children.push(React.createElement('div', {
1928
+ key: 'empty-folder:' + folder,
1929
+ 'data-polish-ws-empty': folder,
1930
+ role: 'group',
1931
+ 'aria-label': folder,
1932
+ title: 'Drop a workspace here to file it in ' + folder,
1933
+ style: Object.assign({}, WS_FOLDER_ROW, {
1934
+ background: dropping === 'folder:' + folder
1935
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1936
+ : undefined,
1937
+ }),
1938
+ onDragOver: (event) => {
1939
+ if (!drag || drag.kind !== 'workspace') return
1940
+ event.preventDefault()
1941
+ setDropping('folder:' + folder)
1942
+ },
1943
+ onDragLeave: () => setDropping(null),
1944
+ onDrop: (event) => {
1945
+ event.preventDefault()
1946
+ setDropping(null)
1947
+ if (!drag || drag.kind !== 'workspace') return
1948
+ const moving = rows.find((entry) => entry.workspace.workspaceId === drag.id)
1949
+ if (!moving) return
1950
+ const title = fileUnder(workspaceLabel(moving.workspace), folder)
1951
+ Promise.resolve()
1952
+ .then(() => services.renameWorkspace(moving.workspace.workspaceId, title))
1953
+ .then(() => setNotice('Filed under ' + folder + '.'))
1954
+ .catch(() => setNotice('Could not file that workspace.'))
1955
+ },
1956
+ },
1957
+ React.createElement('span', { 'aria-hidden': 'true' }, '⌄'),
1958
+ React.createElement('span', { style: WS_TITLE }, folder),
1959
+ React.createElement('span', { style: WS_COUNT_NOTE }, 'empty'),
1960
+ React.createElement('button', {
1961
+ type: 'button',
1962
+ 'data-polish-folder-remove': folder,
1963
+ 'aria-label': 'Remove the empty folder ' + folder,
1964
+ title: 'Remove this empty folder',
1965
+ style: WS_ROW_ACTION,
1966
+ onClick: (event) => {
1967
+ event.stopPropagation()
1968
+ setDeclared(removeFolder(declared, folder))
1969
+ setNotice('Folder removed.')
1970
+ },
1971
+ }, '×')))
1972
+ if (!empty) continue
1973
+ }
1974
+
1756
1975
  if (archivedSessions.length > 0) {
1757
1976
  children.push(React.createElement('div', {
1758
1977
  key: 'archived-head',
@@ -1810,6 +2029,8 @@ window.__ModuleLoader__.load({
1810
2029
  sessionMenu,
1811
2030
  glyphMenu,
1812
2031
  noteMenu,
2032
+ folderMenu,
2033
+ noteCard,
1813
2034
  archivedMenu,
1814
2035
  paintMenu,
1815
2036
  workspaceMenu)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
5
5
  "keywords": [
6
6
  "dsh",