@aryacode/dsh-aryacode-polish 0.17.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,13 @@
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
+
3
11
  ## [0.17.0] - 2026-10-28
4
12
 
5
13
  ### 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
 
@@ -865,10 +927,13 @@ export function BetterWorkspace(props) {
865
927
  const [card, setCard] = React.useState(null)
866
928
  const folded = React.useMemo(() => readFolded(), [])
867
929
  const [folders, setFolders] = React.useState(folded.folders)
930
+ const [declared, setDeclared] = React.useState(() => readFolders())
931
+ const [folderDraft, setFolderDraft] = React.useState('')
868
932
  const [switcher, setSwitcher] = React.useState(null)
869
933
  const [density, setDensity] = React.useState(() => readDensity())
870
934
  const [archiveOpen, setArchiveOpen] = React.useState(false)
871
935
  React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
936
+ React.useEffect(() => { writeFolders(declared) }, [declared])
872
937
  const [renaming, setRenaming] = React.useState(null)
873
938
  const commitRename = async () => {
874
939
  const draft = renaming
@@ -920,7 +985,7 @@ export function BetterWorkspace(props) {
920
985
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
921
986
  const totalCount = allRows.reduce((sum, row) => sum + row.sessions.length, 0)
922
987
  const shownCount = rows.reduce((sum, row) => sum + row.sessions.length, 0)
923
- const folderNames = Array.from(new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean)))
988
+ const folderNames = folderNamesOf(rows, declared)
924
989
  // One click should always do the useful thing: if anything is folded, the button
925
990
  // expands everything (folders included); only when nothing is folded does it fold.
926
991
  const anyFolded = rows.some(row => expanded[row.workspace.workspaceId] === false)
@@ -1124,6 +1189,17 @@ export function BetterWorkspace(props) {
1124
1189
  }
1125
1190
  },
1126
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…'),
1127
1203
  React.createElement('button', {
1128
1204
  type: 'button',
1129
1205
  'data-polish-glyph': 'open',
@@ -1191,6 +1267,30 @@ export function BetterWorkspace(props) {
1191
1267
  }, 'Unarchive session'))
1192
1268
  : null
1193
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
+
1194
1294
  const glyphMenu = menu && menu.kind === 'glyph'
1195
1295
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1196
1296
  React.createElement('div', { style: WS_GLYPH_GRID },
@@ -1506,6 +1606,7 @@ export function BetterWorkspace(props) {
1506
1606
  const folded = folder !== undefined && folders[folder] === true
1507
1607
 
1508
1608
  if (folder !== undefined && !announced[folder]) {
1609
+ /* handled below for both filled and empty folders */
1509
1610
  announced[folder] = true
1510
1611
  const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1511
1612
  children.push(React.createElement('div', {
@@ -1618,6 +1719,58 @@ export function BetterWorkspace(props) {
1618
1719
  }
1619
1720
  })
1620
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
+
1621
1774
  if (archivedSessions.length > 0) {
1622
1775
  children.push(React.createElement('div', {
1623
1776
  key: 'archived-head',
@@ -1675,6 +1828,7 @@ export function BetterWorkspace(props) {
1675
1828
  sessionMenu,
1676
1829
  glyphMenu,
1677
1830
  noteMenu,
1831
+ folderMenu,
1678
1832
  noteCard,
1679
1833
  archivedMenu,
1680
1834
  paintMenu,
@@ -201,3 +201,12 @@ drop target, so the gesture cannot rename something onto itself.
201
201
  A workspace note shows as a small card under the row on hover rather than as a browser
202
202
  tooltip: tooltips are slow to appear, unstyleable, and vanish when the pointer leaves. The
203
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.17.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
 
@@ -1066,10 +1128,13 @@ window.__ModuleLoader__.load({
1066
1128
  const [card, setCard] = React.useState(null)
1067
1129
  const folded = React.useMemo(() => readFolded(), [])
1068
1130
  const [folders, setFolders] = React.useState(folded.folders)
1131
+ const [declared, setDeclared] = React.useState(() => readFolders())
1132
+ const [folderDraft, setFolderDraft] = React.useState('')
1069
1133
  const [switcher, setSwitcher] = React.useState(null)
1070
1134
  const [density, setDensity] = React.useState(() => readDensity())
1071
1135
  const [archiveOpen, setArchiveOpen] = React.useState(false)
1072
1136
  React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
1137
+ React.useEffect(() => { writeFolders(declared) }, [declared])
1073
1138
  const [renaming, setRenaming] = React.useState(null)
1074
1139
  const commitRename = async () => {
1075
1140
  const draft = renaming
@@ -1121,7 +1186,7 @@ window.__ModuleLoader__.load({
1121
1186
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
1122
1187
  const totalCount = allRows.reduce((sum, row) => sum + row.sessions.length, 0)
1123
1188
  const shownCount = rows.reduce((sum, row) => sum + row.sessions.length, 0)
1124
- const folderNames = Array.from(new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean)))
1189
+ const folderNames = folderNamesOf(rows, declared)
1125
1190
  // One click should always do the useful thing: if anything is folded, the button
1126
1191
  // expands everything (folders included); only when nothing is folded does it fold.
1127
1192
  const anyFolded = rows.some(row => expanded[row.workspace.workspaceId] === false)
@@ -1325,6 +1390,17 @@ window.__ModuleLoader__.load({
1325
1390
  }
1326
1391
  },
1327
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…'),
1328
1404
  React.createElement('button', {
1329
1405
  type: 'button',
1330
1406
  'data-polish-glyph': 'open',
@@ -1392,6 +1468,30 @@ window.__ModuleLoader__.load({
1392
1468
  }, 'Unarchive session'))
1393
1469
  : null
1394
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
+
1395
1495
  const glyphMenu = menu && menu.kind === 'glyph'
1396
1496
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1397
1497
  React.createElement('div', { style: WS_GLYPH_GRID },
@@ -1707,6 +1807,7 @@ window.__ModuleLoader__.load({
1707
1807
  const folded = folder !== undefined && folders[folder] === true
1708
1808
 
1709
1809
  if (folder !== undefined && !announced[folder]) {
1810
+ /* handled below for both filled and empty folders */
1710
1811
  announced[folder] = true
1711
1812
  const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1712
1813
  children.push(React.createElement('div', {
@@ -1819,6 +1920,58 @@ window.__ModuleLoader__.load({
1819
1920
  }
1820
1921
  })
1821
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
+
1822
1975
  if (archivedSessions.length > 0) {
1823
1976
  children.push(React.createElement('div', {
1824
1977
  key: 'archived-head',
@@ -1876,6 +2029,7 @@ window.__ModuleLoader__.load({
1876
2029
  sessionMenu,
1877
2030
  glyphMenu,
1878
2031
  noteMenu,
2032
+ folderMenu,
1879
2033
  noteCard,
1880
2034
  archivedMenu,
1881
2035
  paintMenu,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.17.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",