@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 +16 -0
- package/client/workspace.js +225 -4
- package/docs/better-workspace.md +24 -0
- package/lib/client.js +226 -5
- package/package.json +1 -1
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
|
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
|
|
|
@@ -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 =
|
|
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: () =>
|
|
1516
|
-
|
|
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)
|
package/docs/better-workspace.md
CHANGED
|
@@ -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.
|
|
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 =
|
|
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: () =>
|
|
1717
|
-
|
|
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.
|
|
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",
|