@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 +16 -0
- package/client/workspace.js +233 -1
- package/docs/better-workspace.md +24 -0
- package/lib/client.js +234 -2
- package/package.json +1 -1
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
|
package/client/workspace.js
CHANGED
|
@@ -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 =
|
|
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,
|
package/docs/better-workspace.md
CHANGED
|
@@ -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.
|
|
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 =
|
|
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.
|
|
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",
|