@aryacode/dsh-aryacode-polish 0.17.0 → 0.19.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.19.0] - 2026-10-28
4
+
5
+ ### Added
6
+ - **Move to top level** in a workspace menu drops the folder prefix, so filing is reversible.
7
+ - **Rename a folder** from its header: every workspace inside is refiled under the new
8
+ prefix through the Host rename, and the declared/empty list follows the folder.
9
+
10
+
11
+ ## [0.18.0] - 2026-10-28
12
+
13
+ ### Added
14
+ - **Folders that exist before their content.** The workspace menu can declare a folder, an
15
+ empty folder draws its own header with an empty count, accepts a workspace drop, and
16
+ can be removed again. Declared folders are remembered (ryacode-polish:folders.v1).
17
+
18
+
3
19
  ## [0.17.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
 
@@ -645,6 +707,14 @@ export function fileUnder(label, folder) {
645
707
  return bare ? target + '/' + bare : target
646
708
  }
647
709
 
710
+ /** The title a workspace needs to sit at the top level again: no folder prefix. */
711
+ export function unfiled(label) {
712
+ const name = String(label || '').trim()
713
+ if (!name) return name
714
+ const cut = name.lastIndexOf('/')
715
+ return cut >= 0 ? name.slice(cut + 1).trim() : name
716
+ }
717
+
648
718
  export function folderKey(workspace) {
649
719
  const label = workspaceLabel(workspace)
650
720
  const cut = label.lastIndexOf('/')
@@ -865,10 +935,13 @@ export function BetterWorkspace(props) {
865
935
  const [card, setCard] = React.useState(null)
866
936
  const folded = React.useMemo(() => readFolded(), [])
867
937
  const [folders, setFolders] = React.useState(folded.folders)
938
+ const [declared, setDeclared] = React.useState(() => readFolders())
939
+ const [folderDraft, setFolderDraft] = React.useState('')
868
940
  const [switcher, setSwitcher] = React.useState(null)
869
941
  const [density, setDensity] = React.useState(() => readDensity())
870
942
  const [archiveOpen, setArchiveOpen] = React.useState(false)
871
943
  React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
944
+ React.useEffect(() => { writeFolders(declared) }, [declared])
872
945
  const [renaming, setRenaming] = React.useState(null)
873
946
  const commitRename = async () => {
874
947
  const draft = renaming
@@ -920,7 +993,7 @@ export function BetterWorkspace(props) {
920
993
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
921
994
  const totalCount = allRows.reduce((sum, row) => sum + row.sessions.length, 0)
922
995
  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)))
996
+ const folderNames = folderNamesOf(rows, declared)
924
997
  // One click should always do the useful thing: if anything is folded, the button
925
998
  // expands everything (folders included); only when nothing is folded does it fold.
926
999
  const anyFolded = rows.some(row => expanded[row.workspace.workspaceId] === false)
@@ -1124,6 +1197,33 @@ export function BetterWorkspace(props) {
1124
1197
  }
1125
1198
  },
1126
1199
  }, 'Restore saved order'),
1200
+ folderOf(menu.workspace) === undefined
1201
+ ? null
1202
+ : React.createElement('button', {
1203
+ type: 'button',
1204
+ 'data-polish-ws-unfile': menu.workspace.workspaceId,
1205
+ style: WS_MENU_ITEM,
1206
+ onClick: () => {
1207
+ const target = menu.workspace
1208
+ const title = unfiled(workspaceLabel(target))
1209
+ closeMenu()
1210
+ Promise.resolve()
1211
+ .then(() => services.renameWorkspace(target.workspaceId, title))
1212
+ .then(() => setNotice('Moved to the top level.'))
1213
+ .catch(() => setNotice('Could not move that workspace.'))
1214
+ },
1215
+ }, 'Move to top level'),
1216
+ React.createElement('button', {
1217
+ type: 'button',
1218
+ 'data-polish-folder-new': 'open',
1219
+ style: WS_MENU_ITEM,
1220
+ onClick: () => {
1221
+ const anchor = menu.anchor
1222
+ closeMenu()
1223
+ setFolderDraft('')
1224
+ setMenu({ kind: 'folder', anchor })
1225
+ },
1226
+ }, 'New folder…'),
1127
1227
  React.createElement('button', {
1128
1228
  type: 'button',
1129
1229
  'data-polish-glyph': 'open',
@@ -1191,6 +1291,70 @@ export function BetterWorkspace(props) {
1191
1291
  }, 'Unarchive session'))
1192
1292
  : null
1193
1293
 
1294
+ const folderMenu = menu && menu.kind === 'folder'
1295
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1296
+ React.createElement('input', {
1297
+ 'data-polish-folder-input': 'true',
1298
+ type: 'text',
1299
+ value: folderDraft,
1300
+ placeholder: 'Folder name',
1301
+ autoFocus: true,
1302
+ style: WS_NOTE_INPUT,
1303
+ onChange: (event) => setFolderDraft(event.target.value),
1304
+ onKeyDown: (event) => {
1305
+ if (event.key === 'Escape') { event.stopPropagation(); closeMenu(); return }
1306
+ if (event.key !== 'Enter') return
1307
+ const next = addFolder(declared, folderDraft)
1308
+ closeMenu()
1309
+ if (next.length === declared.length) { setNotice('That folder already exists.'); return }
1310
+ setDeclared(next)
1311
+ setFolders((prev) => Object.assign({}, prev, { [next[next.length - 1]]: false }))
1312
+ setNotice('Folder added.')
1313
+ },
1314
+ }),
1315
+ React.createElement('div', { style: WS_MENU_HINT }, 'Enter creates, Escape cancels'))
1316
+ : null
1317
+
1318
+ const renameFolderMenu = menu && menu.kind === 'renameFolder'
1319
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1320
+ React.createElement('input', {
1321
+ 'data-polish-folder-edit-input': 'true',
1322
+ type: 'text',
1323
+ value: folderDraft,
1324
+ placeholder: 'Folder name',
1325
+ autoFocus: true,
1326
+ style: WS_NOTE_INPUT,
1327
+ onChange: (event) => setFolderDraft(event.target.value),
1328
+ onKeyDown: (event) => {
1329
+ if (event.key === 'Escape') { event.stopPropagation(); closeMenu(); return }
1330
+ if (event.key !== 'Enter') return
1331
+ const target = cleanFolder(folderDraft)
1332
+ const moving = menu.members || []
1333
+ const from = menu.folder
1334
+ closeMenu()
1335
+ if (!target || target === from) return
1336
+ Promise.resolve()
1337
+ .then(async () => {
1338
+ for (const workspace of moving) {
1339
+ await services.renameWorkspace(workspace.workspaceId, fileUnder(workspaceLabel(workspace), target))
1340
+ }
1341
+ })
1342
+ .then(() => {
1343
+ setDeclared((prev) => addFolder(removeFolder(prev, from), target))
1344
+ setFolders((prev) => {
1345
+ const next = Object.assign({}, prev)
1346
+ if (next[from] !== undefined) next[target] = next[from]
1347
+ delete next[from]
1348
+ return next
1349
+ })
1350
+ setNotice('Folder renamed.')
1351
+ })
1352
+ .catch(() => setNotice('Could not rename that folder.'))
1353
+ },
1354
+ }),
1355
+ React.createElement('div', { style: WS_MENU_HINT }, 'Renames every workspace inside it'))
1356
+ : null
1357
+
1194
1358
  const glyphMenu = menu && menu.kind === 'glyph'
1195
1359
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1196
1360
  React.createElement('div', { style: WS_GLYPH_GRID },
@@ -1506,6 +1670,7 @@ export function BetterWorkspace(props) {
1506
1670
  const folded = folder !== undefined && folders[folder] === true
1507
1671
 
1508
1672
  if (folder !== undefined && !announced[folder]) {
1673
+ /* handled below for both filled and empty folders */
1509
1674
  announced[folder] = true
1510
1675
  const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1511
1676
  children.push(React.createElement('div', {
@@ -1542,6 +1707,19 @@ export function BetterWorkspace(props) {
1542
1707
  },
1543
1708
  React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1544
1709
  React.createElement('span', { style: WS_TITLE }, folder),
1710
+ React.createElement('button', {
1711
+ type: 'button',
1712
+ 'data-polish-folder-edit': folder,
1713
+ 'aria-label': 'Rename the folder ' + folder,
1714
+ title: 'Rename this folder',
1715
+ style: WS_ROW_ACTION,
1716
+ onClick: (event) => {
1717
+ event.stopPropagation()
1718
+ const box = event.currentTarget.getBoundingClientRect()
1719
+ setFolderDraft(folder)
1720
+ setMenu({ kind: 'renameFolder', folder, anchor: { top: box.bottom + 4, left: box.left }, members: inside.map((entry) => entry.workspace) })
1721
+ },
1722
+ }, '✎'),
1545
1723
  React.createElement('span', { style: WS_COUNT }, String(inside.reduce((sum, entry) => sum + entry.sessions.length, 0)))))
1546
1724
  }
1547
1725
 
@@ -1618,6 +1796,58 @@ export function BetterWorkspace(props) {
1618
1796
  }
1619
1797
  })
1620
1798
 
1799
+ const filled = new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean))
1800
+ for (const folder of folderNames) {
1801
+ if (filled.has(folder)) continue
1802
+ const empty = folders[folder] === true
1803
+ children.push(React.createElement('div', {
1804
+ key: 'empty-folder:' + folder,
1805
+ 'data-polish-ws-empty': folder,
1806
+ role: 'group',
1807
+ 'aria-label': folder,
1808
+ title: 'Drop a workspace here to file it in ' + folder,
1809
+ style: Object.assign({}, WS_FOLDER_ROW, {
1810
+ background: dropping === 'folder:' + folder
1811
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1812
+ : undefined,
1813
+ }),
1814
+ onDragOver: (event) => {
1815
+ if (!drag || drag.kind !== 'workspace') return
1816
+ event.preventDefault()
1817
+ setDropping('folder:' + folder)
1818
+ },
1819
+ onDragLeave: () => setDropping(null),
1820
+ onDrop: (event) => {
1821
+ event.preventDefault()
1822
+ setDropping(null)
1823
+ if (!drag || drag.kind !== 'workspace') return
1824
+ const moving = rows.find((entry) => entry.workspace.workspaceId === drag.id)
1825
+ if (!moving) return
1826
+ const title = fileUnder(workspaceLabel(moving.workspace), folder)
1827
+ Promise.resolve()
1828
+ .then(() => services.renameWorkspace(moving.workspace.workspaceId, title))
1829
+ .then(() => setNotice('Filed under ' + folder + '.'))
1830
+ .catch(() => setNotice('Could not file that workspace.'))
1831
+ },
1832
+ },
1833
+ React.createElement('span', { 'aria-hidden': 'true' }, '⌄'),
1834
+ React.createElement('span', { style: WS_TITLE }, folder),
1835
+ React.createElement('span', { style: WS_COUNT_NOTE }, 'empty'),
1836
+ React.createElement('button', {
1837
+ type: 'button',
1838
+ 'data-polish-folder-remove': folder,
1839
+ 'aria-label': 'Remove the empty folder ' + folder,
1840
+ title: 'Remove this empty folder',
1841
+ style: WS_ROW_ACTION,
1842
+ onClick: (event) => {
1843
+ event.stopPropagation()
1844
+ setDeclared(removeFolder(declared, folder))
1845
+ setNotice('Folder removed.')
1846
+ },
1847
+ }, '×')))
1848
+ if (!empty) continue
1849
+ }
1850
+
1621
1851
  if (archivedSessions.length > 0) {
1622
1852
  children.push(React.createElement('div', {
1623
1853
  key: 'archived-head',
@@ -1675,6 +1905,8 @@ export function BetterWorkspace(props) {
1675
1905
  sessionMenu,
1676
1906
  glyphMenu,
1677
1907
  noteMenu,
1908
+ folderMenu,
1909
+ renameFolderMenu,
1678
1910
  noteCard,
1679
1911
  archivedMenu,
1680
1912
  paintMenu,
@@ -201,3 +201,27 @@ 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.
213
+
214
+ ## Renaming a folder
215
+
216
+ A folder is not an object in the Host — it is the prefix of the titles inside it. So renaming
217
+ a folder means refiling every member: the header's `✎` asks for a new name, and each
218
+ workspace inside is renamed to `NewName/` + its own name through `workspaces.rename`. The
219
+ declared/empty list follows the old name to the new one, and the fold state moves with it, so
220
+ a renamed folder does not suddenly spring open.
221
+
222
+ ## Filing is reversible
223
+
224
+ **Move to top level** appears in a workspace's menu only when it actually lives under a folder,
225
+ and does the inverse: `unfiled('Work/Alpha')` is `Alpha`, renamed through the Host like any
226
+ other title change. A folder left with nothing in it simply stops being drawn, unless it was
227
+ declared empty on purpose.
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.19.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
 
@@ -846,6 +908,14 @@ window.__ModuleLoader__.load({
846
908
  return bare ? target + '/' + bare : target
847
909
  }
848
910
 
911
+ /** The title a workspace needs to sit at the top level again: no folder prefix. */
912
+ function unfiled(label) {
913
+ const name = String(label || '').trim()
914
+ if (!name) return name
915
+ const cut = name.lastIndexOf('/')
916
+ return cut >= 0 ? name.slice(cut + 1).trim() : name
917
+ }
918
+
849
919
  function folderKey(workspace) {
850
920
  const label = workspaceLabel(workspace)
851
921
  const cut = label.lastIndexOf('/')
@@ -1066,10 +1136,13 @@ window.__ModuleLoader__.load({
1066
1136
  const [card, setCard] = React.useState(null)
1067
1137
  const folded = React.useMemo(() => readFolded(), [])
1068
1138
  const [folders, setFolders] = React.useState(folded.folders)
1139
+ const [declared, setDeclared] = React.useState(() => readFolders())
1140
+ const [folderDraft, setFolderDraft] = React.useState('')
1069
1141
  const [switcher, setSwitcher] = React.useState(null)
1070
1142
  const [density, setDensity] = React.useState(() => readDensity())
1071
1143
  const [archiveOpen, setArchiveOpen] = React.useState(false)
1072
1144
  React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
1145
+ React.useEffect(() => { writeFolders(declared) }, [declared])
1073
1146
  const [renaming, setRenaming] = React.useState(null)
1074
1147
  const commitRename = async () => {
1075
1148
  const draft = renaming
@@ -1121,7 +1194,7 @@ window.__ModuleLoader__.load({
1121
1194
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
1122
1195
  const totalCount = allRows.reduce((sum, row) => sum + row.sessions.length, 0)
1123
1196
  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)))
1197
+ const folderNames = folderNamesOf(rows, declared)
1125
1198
  // One click should always do the useful thing: if anything is folded, the button
1126
1199
  // expands everything (folders included); only when nothing is folded does it fold.
1127
1200
  const anyFolded = rows.some(row => expanded[row.workspace.workspaceId] === false)
@@ -1325,6 +1398,33 @@ window.__ModuleLoader__.load({
1325
1398
  }
1326
1399
  },
1327
1400
  }, 'Restore saved order'),
1401
+ folderOf(menu.workspace) === undefined
1402
+ ? null
1403
+ : React.createElement('button', {
1404
+ type: 'button',
1405
+ 'data-polish-ws-unfile': menu.workspace.workspaceId,
1406
+ style: WS_MENU_ITEM,
1407
+ onClick: () => {
1408
+ const target = menu.workspace
1409
+ const title = unfiled(workspaceLabel(target))
1410
+ closeMenu()
1411
+ Promise.resolve()
1412
+ .then(() => services.renameWorkspace(target.workspaceId, title))
1413
+ .then(() => setNotice('Moved to the top level.'))
1414
+ .catch(() => setNotice('Could not move that workspace.'))
1415
+ },
1416
+ }, 'Move to top level'),
1417
+ React.createElement('button', {
1418
+ type: 'button',
1419
+ 'data-polish-folder-new': 'open',
1420
+ style: WS_MENU_ITEM,
1421
+ onClick: () => {
1422
+ const anchor = menu.anchor
1423
+ closeMenu()
1424
+ setFolderDraft('')
1425
+ setMenu({ kind: 'folder', anchor })
1426
+ },
1427
+ }, 'New folder…'),
1328
1428
  React.createElement('button', {
1329
1429
  type: 'button',
1330
1430
  'data-polish-glyph': 'open',
@@ -1392,6 +1492,70 @@ window.__ModuleLoader__.load({
1392
1492
  }, 'Unarchive session'))
1393
1493
  : null
1394
1494
 
1495
+ const folderMenu = menu && menu.kind === 'folder'
1496
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1497
+ React.createElement('input', {
1498
+ 'data-polish-folder-input': 'true',
1499
+ type: 'text',
1500
+ value: folderDraft,
1501
+ placeholder: 'Folder name',
1502
+ autoFocus: true,
1503
+ style: WS_NOTE_INPUT,
1504
+ onChange: (event) => setFolderDraft(event.target.value),
1505
+ onKeyDown: (event) => {
1506
+ if (event.key === 'Escape') { event.stopPropagation(); closeMenu(); return }
1507
+ if (event.key !== 'Enter') return
1508
+ const next = addFolder(declared, folderDraft)
1509
+ closeMenu()
1510
+ if (next.length === declared.length) { setNotice('That folder already exists.'); return }
1511
+ setDeclared(next)
1512
+ setFolders((prev) => Object.assign({}, prev, { [next[next.length - 1]]: false }))
1513
+ setNotice('Folder added.')
1514
+ },
1515
+ }),
1516
+ React.createElement('div', { style: WS_MENU_HINT }, 'Enter creates, Escape cancels'))
1517
+ : null
1518
+
1519
+ const renameFolderMenu = menu && menu.kind === 'renameFolder'
1520
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1521
+ React.createElement('input', {
1522
+ 'data-polish-folder-edit-input': 'true',
1523
+ type: 'text',
1524
+ value: folderDraft,
1525
+ placeholder: 'Folder name',
1526
+ autoFocus: true,
1527
+ style: WS_NOTE_INPUT,
1528
+ onChange: (event) => setFolderDraft(event.target.value),
1529
+ onKeyDown: (event) => {
1530
+ if (event.key === 'Escape') { event.stopPropagation(); closeMenu(); return }
1531
+ if (event.key !== 'Enter') return
1532
+ const target = cleanFolder(folderDraft)
1533
+ const moving = menu.members || []
1534
+ const from = menu.folder
1535
+ closeMenu()
1536
+ if (!target || target === from) return
1537
+ Promise.resolve()
1538
+ .then(async () => {
1539
+ for (const workspace of moving) {
1540
+ await services.renameWorkspace(workspace.workspaceId, fileUnder(workspaceLabel(workspace), target))
1541
+ }
1542
+ })
1543
+ .then(() => {
1544
+ setDeclared((prev) => addFolder(removeFolder(prev, from), target))
1545
+ setFolders((prev) => {
1546
+ const next = Object.assign({}, prev)
1547
+ if (next[from] !== undefined) next[target] = next[from]
1548
+ delete next[from]
1549
+ return next
1550
+ })
1551
+ setNotice('Folder renamed.')
1552
+ })
1553
+ .catch(() => setNotice('Could not rename that folder.'))
1554
+ },
1555
+ }),
1556
+ React.createElement('div', { style: WS_MENU_HINT }, 'Renames every workspace inside it'))
1557
+ : null
1558
+
1395
1559
  const glyphMenu = menu && menu.kind === 'glyph'
1396
1560
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1397
1561
  React.createElement('div', { style: WS_GLYPH_GRID },
@@ -1707,6 +1871,7 @@ window.__ModuleLoader__.load({
1707
1871
  const folded = folder !== undefined && folders[folder] === true
1708
1872
 
1709
1873
  if (folder !== undefined && !announced[folder]) {
1874
+ /* handled below for both filled and empty folders */
1710
1875
  announced[folder] = true
1711
1876
  const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1712
1877
  children.push(React.createElement('div', {
@@ -1743,6 +1908,19 @@ window.__ModuleLoader__.load({
1743
1908
  },
1744
1909
  React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1745
1910
  React.createElement('span', { style: WS_TITLE }, folder),
1911
+ React.createElement('button', {
1912
+ type: 'button',
1913
+ 'data-polish-folder-edit': folder,
1914
+ 'aria-label': 'Rename the folder ' + folder,
1915
+ title: 'Rename this folder',
1916
+ style: WS_ROW_ACTION,
1917
+ onClick: (event) => {
1918
+ event.stopPropagation()
1919
+ const box = event.currentTarget.getBoundingClientRect()
1920
+ setFolderDraft(folder)
1921
+ setMenu({ kind: 'renameFolder', folder, anchor: { top: box.bottom + 4, left: box.left }, members: inside.map((entry) => entry.workspace) })
1922
+ },
1923
+ }, '✎'),
1746
1924
  React.createElement('span', { style: WS_COUNT }, String(inside.reduce((sum, entry) => sum + entry.sessions.length, 0)))))
1747
1925
  }
1748
1926
 
@@ -1819,6 +1997,58 @@ window.__ModuleLoader__.load({
1819
1997
  }
1820
1998
  })
1821
1999
 
2000
+ const filled = new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean))
2001
+ for (const folder of folderNames) {
2002
+ if (filled.has(folder)) continue
2003
+ const empty = folders[folder] === true
2004
+ children.push(React.createElement('div', {
2005
+ key: 'empty-folder:' + folder,
2006
+ 'data-polish-ws-empty': folder,
2007
+ role: 'group',
2008
+ 'aria-label': folder,
2009
+ title: 'Drop a workspace here to file it in ' + folder,
2010
+ style: Object.assign({}, WS_FOLDER_ROW, {
2011
+ background: dropping === 'folder:' + folder
2012
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
2013
+ : undefined,
2014
+ }),
2015
+ onDragOver: (event) => {
2016
+ if (!drag || drag.kind !== 'workspace') return
2017
+ event.preventDefault()
2018
+ setDropping('folder:' + folder)
2019
+ },
2020
+ onDragLeave: () => setDropping(null),
2021
+ onDrop: (event) => {
2022
+ event.preventDefault()
2023
+ setDropping(null)
2024
+ if (!drag || drag.kind !== 'workspace') return
2025
+ const moving = rows.find((entry) => entry.workspace.workspaceId === drag.id)
2026
+ if (!moving) return
2027
+ const title = fileUnder(workspaceLabel(moving.workspace), folder)
2028
+ Promise.resolve()
2029
+ .then(() => services.renameWorkspace(moving.workspace.workspaceId, title))
2030
+ .then(() => setNotice('Filed under ' + folder + '.'))
2031
+ .catch(() => setNotice('Could not file that workspace.'))
2032
+ },
2033
+ },
2034
+ React.createElement('span', { 'aria-hidden': 'true' }, '⌄'),
2035
+ React.createElement('span', { style: WS_TITLE }, folder),
2036
+ React.createElement('span', { style: WS_COUNT_NOTE }, 'empty'),
2037
+ React.createElement('button', {
2038
+ type: 'button',
2039
+ 'data-polish-folder-remove': folder,
2040
+ 'aria-label': 'Remove the empty folder ' + folder,
2041
+ title: 'Remove this empty folder',
2042
+ style: WS_ROW_ACTION,
2043
+ onClick: (event) => {
2044
+ event.stopPropagation()
2045
+ setDeclared(removeFolder(declared, folder))
2046
+ setNotice('Folder removed.')
2047
+ },
2048
+ }, '×')))
2049
+ if (!empty) continue
2050
+ }
2051
+
1822
2052
  if (archivedSessions.length > 0) {
1823
2053
  children.push(React.createElement('div', {
1824
2054
  key: 'archived-head',
@@ -1876,6 +2106,8 @@ window.__ModuleLoader__.load({
1876
2106
  sessionMenu,
1877
2107
  glyphMenu,
1878
2108
  noteMenu,
2109
+ folderMenu,
2110
+ renameFolderMenu,
1879
2111
  noteCard,
1880
2112
  archivedMenu,
1881
2113
  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.19.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",