@aryacode/dsh-aryacode-polish 0.19.0 → 0.21.2

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.21.2] - 2026-10-28
4
+
5
+ Fixed: the folders-only density showed a zero count because it read the hidden session list instead of the real one.
6
+
7
+ - 2026-10-28
8
+
9
+ Added: a folders-only density that hides session rows, and [ / ] to hop between workspaces.
10
+
11
+ ## [0.20.0] - 2026-10-28
12
+
13
+ Added: session flags and notes, shown on the row and on hover; the whole tree can be copied as indented text from the header.
14
+
3
15
  ## [0.19.0] - 2026-10-28
4
16
 
5
17
  ### Added
@@ -371,6 +371,23 @@ export function flattenRows(rows, expanded, query) {
371
371
  return flat
372
372
  }
373
373
 
374
+ /** Where the cursor lands when hopping to the next or previous workspace head. */
375
+ export function jumpWorkspace(list, cursor, step) {
376
+ const flat = Array.isArray(list) ? list : []
377
+ const heads = []
378
+ flat.forEach((item, index) => {
379
+ if (item && item.kind === 'workspace') heads.push(index)
380
+ })
381
+ if (heads.length === 0) return -1
382
+ if (cursor === undefined || cursor < 0) return step > 0 ? heads[0] : heads[heads.length - 1]
383
+ if (step > 0) {
384
+ const after = heads.find(index => index > cursor)
385
+ return after === undefined ? heads[0] : after
386
+ }
387
+ const before = heads.filter(index => index < cursor)
388
+ return before.length === 0 ? heads[heads.length - 1] : before[before.length - 1]
389
+ }
390
+
374
391
  export function moveBefore(list, movingId, beforeId) {
375
392
  const from = list.findIndex(item => item.id === movingId)
376
393
  const to = list.findIndex(item => item.id === beforeId)
@@ -387,6 +404,79 @@ const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
387
404
  const FOLD_KEY = 'aryacode-polish:folded.v1'
388
405
  const DENSITY_KEY = 'aryacode-polish:density.v1'
389
406
  const FOLDER_KEY = 'aryacode-polish:folders.v1'
407
+ const MARK_KEY = 'aryacode-polish:session-marks.v1'
408
+
409
+ export function markOf(map, sessionId) {
410
+ const entry = (map && map[String(sessionId)]) || {}
411
+ return {
412
+ flag: entry.flag === true,
413
+ note: typeof entry.note === 'string' && entry.note.length > 0 ? entry.note : undefined,
414
+ }
415
+ }
416
+
417
+ export function withMark(map, sessionId, patch) {
418
+ const next = Object.assign({}, map || {})
419
+ const key = String(sessionId)
420
+ const merged = Object.assign({}, markOf(next, key), patch || {})
421
+ const entry = {}
422
+ if (merged.flag === true) entry.flag = true
423
+ if (merged.note) entry.note = merged.note
424
+ if (Object.keys(entry).length === 0) delete next[key]
425
+ else next[key] = entry
426
+ return next
427
+ }
428
+
429
+ export function readMarks(storage) {
430
+ try {
431
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
432
+ if (!store) return {}
433
+ const parsed = JSON.parse(store.getItem(MARK_KEY) || '{}')
434
+ return parsed && typeof parsed === 'object' ? parsed : {}
435
+ } catch {
436
+ return {}
437
+ }
438
+ }
439
+
440
+ export function writeMarks(map, storage) {
441
+ try {
442
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
443
+ if (!store) return
444
+ store.setItem(MARK_KEY, JSON.stringify(map || {}))
445
+ } catch {
446
+ /* a mark is a nicety */
447
+ }
448
+ }
449
+
450
+ /** The whole tree as indented text, for pasting into a prompt or an issue. */
451
+ export function treeText(rows, folderNames, markOfSession) {
452
+ const lines = []
453
+ const seen = new Set()
454
+ const pushGroup = (folder) => {
455
+ if (seen.has(folder)) return
456
+ seen.add(folder)
457
+ lines.push(folder)
458
+ for (const row of rows || []) {
459
+ const name = folderKey(row.workspace)
460
+ if (name !== folder) continue
461
+ lines.push(' ' + workspaceLabel(row.workspace))
462
+ for (const session of row.sessions || []) {
463
+ const mark = typeof markOfSession === 'function' ? markOfSession(session.id) : { flag: false }
464
+ lines.push(' ' + (mark && mark.flag ? '⚑ ' : '') + String(session.displayTitle || session.title || session.id))
465
+ }
466
+ }
467
+ }
468
+ for (const folder of folderNames || []) pushGroup(folder)
469
+ for (const row of rows || []) {
470
+ const name = folderKey(row.workspace)
471
+ if (name !== undefined) continue
472
+ lines.push(workspaceLabel(row.workspace))
473
+ for (const session of row.sessions || []) {
474
+ const mark = typeof markOfSession === 'function' ? markOfSession(session.id) : { flag: false }
475
+ lines.push(' ' + (mark && mark.flag ? '⚑ ' : '') + String(session.displayTitle || session.title || session.id))
476
+ }
477
+ }
478
+ return lines.join('\n')
479
+ }
390
480
 
391
481
  const cleanFolder = (name) => {
392
482
  const value = String(name || '').trim().replace(/^\/+|\/+$/g, '')
@@ -449,16 +539,34 @@ export function folderNamesOf(rows, declared) {
449
539
  return seen
450
540
  }
451
541
 
452
- export const DENSITIES = ['comfortable', 'compact']
542
+ export const DENSITIES = ['comfortable', 'compact', 'folders']
453
543
 
454
544
  /** Row height per density: the tree fits more rows without shrinking the text. */
455
545
  export const DENSITY_METRICS = {
456
- comfortable: { row: '28px' },
457
- compact: { row: '22px' },
546
+ comfortable: { row: '28px', sessions: true },
547
+ compact: { row: '22px', sessions: true },
548
+ folders: { row: '24px', sessions: false },
549
+ }
550
+
551
+ export const DENSITY_TITLES = {
552
+ comfortable: 'Use compact rows',
553
+ compact: 'Show folders only',
554
+ folders: 'Use comfortable rows',
555
+ }
556
+
557
+ export const DENSITY_GLYPHS = {
558
+ comfortable: '\u2263',
559
+ compact: '\u2261',
560
+ folders: '\u25a4',
458
561
  }
459
562
 
460
563
  export function densityOf(value) {
461
- return value === 'compact' ? 'compact' : 'comfortable'
564
+ return DENSITIES.indexOf(value) >= 0 ? value : 'comfortable'
565
+ }
566
+
567
+ export function nextDensity(value) {
568
+ const list = DENSITIES
569
+ return list[(list.indexOf(densityOf(value)) + 1) % list.length]
462
570
  }
463
571
 
464
572
  export function readDensity(storage) {
@@ -937,11 +1045,14 @@ export function BetterWorkspace(props) {
937
1045
  const [folders, setFolders] = React.useState(folded.folders)
938
1046
  const [declared, setDeclared] = React.useState(() => readFolders())
939
1047
  const [folderDraft, setFolderDraft] = React.useState('')
1048
+ const [marks, setMarks] = React.useState(() => readMarks())
1049
+ const [markDraft, setMarkDraft] = React.useState('')
940
1050
  const [switcher, setSwitcher] = React.useState(null)
941
1051
  const [density, setDensity] = React.useState(() => readDensity())
942
1052
  const [archiveOpen, setArchiveOpen] = React.useState(false)
943
1053
  React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
944
1054
  React.useEffect(() => { writeFolders(declared) }, [declared])
1055
+ React.useEffect(() => { writeMarks(marks) }, [marks])
945
1056
  const [renaming, setRenaming] = React.useState(null)
946
1057
  const commitRename = async () => {
947
1058
  const draft = renaming
@@ -1117,7 +1228,17 @@ export function BetterWorkspace(props) {
1117
1228
  if (item.kind === 'workspace') toggleWorkspace(item.workspaceId)
1118
1229
  else services.openSession(item.id)
1119
1230
  }
1231
+ /** [ and ] hop between workspace heads: the fast way through a long tree. */
1232
+ const hopWorkspace = (step) => {
1233
+ const next = jumpWorkspace(walk, cursor, step)
1234
+ if (next >= 0) setCursor(next)
1235
+ }
1120
1236
  const onKeyDown = (event) => {
1237
+ if (event.key === '[' || event.key === ']') {
1238
+ event.preventDefault()
1239
+ hopWorkspace(event.key === ']' ? 1 : -1)
1240
+ return
1241
+ }
1121
1242
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1122
1243
  if (walk.length === 0) return
1123
1244
  event.preventDefault()
@@ -1355,6 +1476,28 @@ export function BetterWorkspace(props) {
1355
1476
  React.createElement('div', { style: WS_MENU_HINT }, 'Renames every workspace inside it'))
1356
1477
  : null
1357
1478
 
1479
+ const markMenu = menu && menu.kind === 'mark'
1480
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1481
+ React.createElement('input', {
1482
+ 'data-polish-mark-input': 'true',
1483
+ type: 'text',
1484
+ value: markDraft,
1485
+ placeholder: 'A note for this session',
1486
+ autoFocus: true,
1487
+ style: WS_NOTE_INPUT,
1488
+ onChange: (event) => setMarkDraft(event.target.value),
1489
+ onKeyDown: (event) => {
1490
+ if (event.key === 'Escape') { event.stopPropagation(); closeMenu(); return }
1491
+ if (event.key !== 'Enter') return
1492
+ const id = menu.row.id
1493
+ const value = markDraft.trim()
1494
+ closeMenu()
1495
+ setMarks((prev) => withMark(prev, id, { note: value }))
1496
+ },
1497
+ }),
1498
+ React.createElement('div', { style: WS_MENU_HINT }, 'Enter saves, Escape cancels'))
1499
+ : null
1500
+
1358
1501
  const glyphMenu = menu && menu.kind === 'glyph'
1359
1502
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1360
1503
  React.createElement('div', { style: WS_GLYPH_GRID },
@@ -1463,7 +1606,29 @@ export function BetterWorkspace(props) {
1463
1606
  workspacesSnapshot: workspaceState,
1464
1607
  session: { sessionId: menu.row.id, displayTitle: menu.row.displayTitle, session: menu.row },
1465
1608
  }))),
1466
- null)
1609
+ React.createElement('button', {
1610
+ type: 'button',
1611
+ 'data-polish-session-flag': menu.row.id,
1612
+ style: WS_MENU_ITEM,
1613
+ onClick: () => {
1614
+ const row = menu.row
1615
+ const current = markOf(marks, row.id)
1616
+ closeMenu()
1617
+ setMarks((prev) => withMark(prev, row.id, { flag: !current.flag }))
1618
+ },
1619
+ }, markOf(marks, menu.row.id).flag ? 'Remove flag' : 'Flag this session'),
1620
+ React.createElement('button', {
1621
+ type: 'button',
1622
+ 'data-polish-session-note': menu.row.id,
1623
+ style: WS_MENU_ITEM,
1624
+ onClick: () => {
1625
+ const row = menu.row
1626
+ const anchor = menu.anchor
1627
+ closeMenu()
1628
+ setMarkDraft(markOf(marks, row.id).note || '')
1629
+ setMenu({ kind: 'mark', row, anchor })
1630
+ },
1631
+ }, 'Session note…'))
1467
1632
  : null
1468
1633
 
1469
1634
  const switcherCard = switcher
@@ -1529,19 +1694,33 @@ export function BetterWorkspace(props) {
1529
1694
  style: WS_ADD,
1530
1695
  onClick: foldAll,
1531
1696
  }, allFolded ? '⌄' : '⌃'),
1697
+ React.createElement('button', {
1698
+ key: 'copy',
1699
+ type: 'button',
1700
+ 'data-polish-ws-copy': 'tree',
1701
+ title: 'Copy the tree as text',
1702
+ 'aria-label': 'Copy the tree as text',
1703
+ style: WS_ADD,
1704
+ onClick: () => {
1705
+ const text = treeText(rows, folderNames, (id) => markOf(marks, id))
1706
+ Promise.resolve(navigator.clipboard.writeText(text))
1707
+ .then(() => setNotice('Tree copied.'))
1708
+ .catch(() => setNotice('Could not copy the tree.'))
1709
+ },
1710
+ }, '⧉'),
1532
1711
  React.createElement('button', {
1533
1712
  key: 'density',
1534
1713
  type: 'button',
1535
1714
  'data-polish-ws-density': densityOf(density),
1536
- title: densityOf(density) === 'compact' ? 'Use comfortable rows' : 'Use compact rows',
1537
- 'aria-label': densityOf(density) === 'compact' ? 'Use comfortable rows' : 'Use compact rows',
1715
+ title: DENSITY_TITLES[densityOf(density)],
1716
+ 'aria-label': DENSITY_TITLES[densityOf(density)],
1538
1717
  style: WS_ADD,
1539
1718
  onClick: () => {
1540
- const next = densityOf(density) === 'compact' ? 'comfortable' : 'compact'
1719
+ const next = nextDensity(density)
1541
1720
  setDensity(next)
1542
1721
  writeDensity(next)
1543
1722
  },
1544
- }, densityOf(density) === 'compact' ? '≡' : '≣'),
1723
+ }, DENSITY_GLYPHS[densityOf(density)]),
1545
1724
  React.createElement('button', {
1546
1725
  key: 'add',
1547
1726
  type: 'button',
@@ -1638,8 +1817,14 @@ export function BetterWorkspace(props) {
1638
1817
  }),
1639
1818
  onClick: () => services.openSession(row.id),
1640
1819
  onContextMenu: (event) => openMenu(event, row, workspace),
1641
- onMouseEnter: () => setHover('session:' + row.id),
1642
- onMouseLeave: () => setHover(null),
1820
+ onMouseEnter: (event) => {
1821
+ setHover('session:' + row.id)
1822
+ const note = markOf(marks, row.id).note
1823
+ if (!note) return
1824
+ const box = event.currentTarget.getBoundingClientRect()
1825
+ setCard({ text: note, top: box.bottom + 6, left: Math.max(8, box.left) })
1826
+ },
1827
+ onMouseLeave: () => { setHover(null); setCard(null) },
1643
1828
  onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
1644
1829
  onDragOver: (event) => {
1645
1830
  if (!drag || drag.kind !== 'session' || drag.id === row.id) return
@@ -1651,7 +1836,14 @@ export function BetterWorkspace(props) {
1651
1836
  },
1652
1837
  row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1653
1838
  pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1654
- React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
1839
+ markOf(marks, row.id).flag
1840
+ ? React.createElement('span', { 'data-polish-mark-flag': row.id, style: WS_PIN, title: 'Flagged' }, '⚑')
1841
+ : null,
1842
+ React.createElement('span', {
1843
+ style: Object.assign({}, WS_TITLE, {
1844
+ fontStyle: markOf(marks, row.id).note ? 'italic' : undefined,
1845
+ }),
1846
+ }, row.displayTitle || row.id),
1655
1847
  rowHovered || rowFocused
1656
1848
  ? React.createElement('button', {
1657
1849
  type: 'button',
@@ -1789,7 +1981,7 @@ export function BetterWorkspace(props) {
1789
1981
  : null)))
1790
1982
 
1791
1983
  if (open) {
1792
- sessions.forEach((row) => {
1984
+ (metrics.sessions ? sessions : []).forEach((row) => {
1793
1985
  index += 1
1794
1986
  children.push(sessionRow(workspace, row, index))
1795
1987
  })
@@ -1907,6 +2099,7 @@ export function BetterWorkspace(props) {
1907
2099
  noteMenu,
1908
2100
  folderMenu,
1909
2101
  renameFolderMenu,
2102
+ markMenu,
1910
2103
  noteCard,
1911
2104
  archivedMenu,
1912
2105
  paintMenu,
@@ -225,3 +225,35 @@ a renamed folder does not suddenly spring open.
225
225
  and does the inverse: `unfiled('Work/Alpha')` is `Alpha`, renamed through the Host like any
226
226
  other title change. A folder left with nothing in it simply stops being drawn, unless it was
227
227
  declared empty on purpose.
228
+
229
+ ## Flags and notes on sessions
230
+
231
+ A session can carry a flag (`⚑`, shown before its title) and a note (shown on hover, in the same
232
+ card the workspace notes use). Both live in `aryacode-polish:session-marks.v1`, keyed by session
233
+ id, and both are set from the session's own menu: **Flag this session** / **Remove flag** and
234
+ **Session note…** (Enter saves, Escape cancels). The tree never invents a session, so a mark for a
235
+ session that no longer exists is simply never drawn.
236
+
237
+ ## Copy the tree
238
+
239
+ The header's `⧉` copies the whole tree as indented text — folders, workspaces, sessions, with
240
+ `⚑` before a flagged session — through `navigator.clipboard.writeText`. It is meant for pasting
241
+ into a prompt or an issue: "here is what I am looking at", without screenshots.
242
+
243
+ ## Folders only
244
+
245
+ The density button walks three states, not two: comfortable, compact, and **folders only**. The
246
+ last one keeps every folder and workspace row (with its real session count) and drops the session
247
+ rows entirely — useful when you are navigating the shape of your work rather than a specific
248
+ session. Nothing is lost: one more click returns to comfortable, and the mode is remembered in
249
+ `aryacode-polish:density.v1`.
250
+
251
+ One trap it exposed: the workspace count must come from the unfiltered session list. Reading the
252
+ list the tree was about to draw made every workspace read `0` in this mode, which looked like the
253
+ feature had eaten the sessions.
254
+
255
+ ## Hopping between workspaces
256
+
257
+ With the tree focused, `[` and `]` move the keyboard cursor to the previous / next workspace head
258
+ and wrap at the ends. It is a second gear next to ArrowUp/ArrowDown: arrows walk rows, brackets
259
+ walk groups, so a long flat list of sessions stops being a scroll-and-squint exercise.
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.19.0"
56
+ const CLIENT_VERSION = "0.21.2"
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. */
@@ -572,6 +572,23 @@ window.__ModuleLoader__.load({
572
572
  return flat
573
573
  }
574
574
 
575
+ /** Where the cursor lands when hopping to the next or previous workspace head. */
576
+ function jumpWorkspace(list, cursor, step) {
577
+ const flat = Array.isArray(list) ? list : []
578
+ const heads = []
579
+ flat.forEach((item, index) => {
580
+ if (item && item.kind === 'workspace') heads.push(index)
581
+ })
582
+ if (heads.length === 0) return -1
583
+ if (cursor === undefined || cursor < 0) return step > 0 ? heads[0] : heads[heads.length - 1]
584
+ if (step > 0) {
585
+ const after = heads.find(index => index > cursor)
586
+ return after === undefined ? heads[0] : after
587
+ }
588
+ const before = heads.filter(index => index < cursor)
589
+ return before.length === 0 ? heads[heads.length - 1] : before[before.length - 1]
590
+ }
591
+
575
592
  function moveBefore(list, movingId, beforeId) {
576
593
  const from = list.findIndex(item => item.id === movingId)
577
594
  const to = list.findIndex(item => item.id === beforeId)
@@ -588,6 +605,79 @@ window.__ModuleLoader__.load({
588
605
  const FOLD_KEY = 'aryacode-polish:folded.v1'
589
606
  const DENSITY_KEY = 'aryacode-polish:density.v1'
590
607
  const FOLDER_KEY = 'aryacode-polish:folders.v1'
608
+ const MARK_KEY = 'aryacode-polish:session-marks.v1'
609
+
610
+ function markOf(map, sessionId) {
611
+ const entry = (map && map[String(sessionId)]) || {}
612
+ return {
613
+ flag: entry.flag === true,
614
+ note: typeof entry.note === 'string' && entry.note.length > 0 ? entry.note : undefined,
615
+ }
616
+ }
617
+
618
+ function withMark(map, sessionId, patch) {
619
+ const next = Object.assign({}, map || {})
620
+ const key = String(sessionId)
621
+ const merged = Object.assign({}, markOf(next, key), patch || {})
622
+ const entry = {}
623
+ if (merged.flag === true) entry.flag = true
624
+ if (merged.note) entry.note = merged.note
625
+ if (Object.keys(entry).length === 0) delete next[key]
626
+ else next[key] = entry
627
+ return next
628
+ }
629
+
630
+ function readMarks(storage) {
631
+ try {
632
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
633
+ if (!store) return {}
634
+ const parsed = JSON.parse(store.getItem(MARK_KEY) || '{}')
635
+ return parsed && typeof parsed === 'object' ? parsed : {}
636
+ } catch {
637
+ return {}
638
+ }
639
+ }
640
+
641
+ function writeMarks(map, storage) {
642
+ try {
643
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
644
+ if (!store) return
645
+ store.setItem(MARK_KEY, JSON.stringify(map || {}))
646
+ } catch {
647
+ /* a mark is a nicety */
648
+ }
649
+ }
650
+
651
+ /** The whole tree as indented text, for pasting into a prompt or an issue. */
652
+ function treeText(rows, folderNames, markOfSession) {
653
+ const lines = []
654
+ const seen = new Set()
655
+ const pushGroup = (folder) => {
656
+ if (seen.has(folder)) return
657
+ seen.add(folder)
658
+ lines.push(folder)
659
+ for (const row of rows || []) {
660
+ const name = folderKey(row.workspace)
661
+ if (name !== folder) continue
662
+ lines.push(' ' + workspaceLabel(row.workspace))
663
+ for (const session of row.sessions || []) {
664
+ const mark = typeof markOfSession === 'function' ? markOfSession(session.id) : { flag: false }
665
+ lines.push(' ' + (mark && mark.flag ? '⚑ ' : '') + String(session.displayTitle || session.title || session.id))
666
+ }
667
+ }
668
+ }
669
+ for (const folder of folderNames || []) pushGroup(folder)
670
+ for (const row of rows || []) {
671
+ const name = folderKey(row.workspace)
672
+ if (name !== undefined) continue
673
+ lines.push(workspaceLabel(row.workspace))
674
+ for (const session of row.sessions || []) {
675
+ const mark = typeof markOfSession === 'function' ? markOfSession(session.id) : { flag: false }
676
+ lines.push(' ' + (mark && mark.flag ? '⚑ ' : '') + String(session.displayTitle || session.title || session.id))
677
+ }
678
+ }
679
+ return lines.join('\n')
680
+ }
591
681
 
592
682
  const cleanFolder = (name) => {
593
683
  const value = String(name || '').trim().replace(/^\/+|\/+$/g, '')
@@ -650,16 +740,34 @@ window.__ModuleLoader__.load({
650
740
  return seen
651
741
  }
652
742
 
653
- const DENSITIES = ['comfortable', 'compact']
743
+ const DENSITIES = ['comfortable', 'compact', 'folders']
654
744
 
655
745
  /** Row height per density: the tree fits more rows without shrinking the text. */
656
746
  const DENSITY_METRICS = {
657
- comfortable: { row: '28px' },
658
- compact: { row: '22px' },
747
+ comfortable: { row: '28px', sessions: true },
748
+ compact: { row: '22px', sessions: true },
749
+ folders: { row: '24px', sessions: false },
750
+ }
751
+
752
+ const DENSITY_TITLES = {
753
+ comfortable: 'Use compact rows',
754
+ compact: 'Show folders only',
755
+ folders: 'Use comfortable rows',
756
+ }
757
+
758
+ const DENSITY_GLYPHS = {
759
+ comfortable: '\u2263',
760
+ compact: '\u2261',
761
+ folders: '\u25a4',
659
762
  }
660
763
 
661
764
  function densityOf(value) {
662
- return value === 'compact' ? 'compact' : 'comfortable'
765
+ return DENSITIES.indexOf(value) >= 0 ? value : 'comfortable'
766
+ }
767
+
768
+ function nextDensity(value) {
769
+ const list = DENSITIES
770
+ return list[(list.indexOf(densityOf(value)) + 1) % list.length]
663
771
  }
664
772
 
665
773
  function readDensity(storage) {
@@ -1138,11 +1246,14 @@ window.__ModuleLoader__.load({
1138
1246
  const [folders, setFolders] = React.useState(folded.folders)
1139
1247
  const [declared, setDeclared] = React.useState(() => readFolders())
1140
1248
  const [folderDraft, setFolderDraft] = React.useState('')
1249
+ const [marks, setMarks] = React.useState(() => readMarks())
1250
+ const [markDraft, setMarkDraft] = React.useState('')
1141
1251
  const [switcher, setSwitcher] = React.useState(null)
1142
1252
  const [density, setDensity] = React.useState(() => readDensity())
1143
1253
  const [archiveOpen, setArchiveOpen] = React.useState(false)
1144
1254
  React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
1145
1255
  React.useEffect(() => { writeFolders(declared) }, [declared])
1256
+ React.useEffect(() => { writeMarks(marks) }, [marks])
1146
1257
  const [renaming, setRenaming] = React.useState(null)
1147
1258
  const commitRename = async () => {
1148
1259
  const draft = renaming
@@ -1318,7 +1429,17 @@ window.__ModuleLoader__.load({
1318
1429
  if (item.kind === 'workspace') toggleWorkspace(item.workspaceId)
1319
1430
  else services.openSession(item.id)
1320
1431
  }
1432
+ /** [ and ] hop between workspace heads: the fast way through a long tree. */
1433
+ const hopWorkspace = (step) => {
1434
+ const next = jumpWorkspace(walk, cursor, step)
1435
+ if (next >= 0) setCursor(next)
1436
+ }
1321
1437
  const onKeyDown = (event) => {
1438
+ if (event.key === '[' || event.key === ']') {
1439
+ event.preventDefault()
1440
+ hopWorkspace(event.key === ']' ? 1 : -1)
1441
+ return
1442
+ }
1322
1443
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1323
1444
  if (walk.length === 0) return
1324
1445
  event.preventDefault()
@@ -1556,6 +1677,28 @@ window.__ModuleLoader__.load({
1556
1677
  React.createElement('div', { style: WS_MENU_HINT }, 'Renames every workspace inside it'))
1557
1678
  : null
1558
1679
 
1680
+ const markMenu = menu && menu.kind === 'mark'
1681
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1682
+ React.createElement('input', {
1683
+ 'data-polish-mark-input': 'true',
1684
+ type: 'text',
1685
+ value: markDraft,
1686
+ placeholder: 'A note for this session',
1687
+ autoFocus: true,
1688
+ style: WS_NOTE_INPUT,
1689
+ onChange: (event) => setMarkDraft(event.target.value),
1690
+ onKeyDown: (event) => {
1691
+ if (event.key === 'Escape') { event.stopPropagation(); closeMenu(); return }
1692
+ if (event.key !== 'Enter') return
1693
+ const id = menu.row.id
1694
+ const value = markDraft.trim()
1695
+ closeMenu()
1696
+ setMarks((prev) => withMark(prev, id, { note: value }))
1697
+ },
1698
+ }),
1699
+ React.createElement('div', { style: WS_MENU_HINT }, 'Enter saves, Escape cancels'))
1700
+ : null
1701
+
1559
1702
  const glyphMenu = menu && menu.kind === 'glyph'
1560
1703
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1561
1704
  React.createElement('div', { style: WS_GLYPH_GRID },
@@ -1664,7 +1807,29 @@ window.__ModuleLoader__.load({
1664
1807
  workspacesSnapshot: workspaceState,
1665
1808
  session: { sessionId: menu.row.id, displayTitle: menu.row.displayTitle, session: menu.row },
1666
1809
  }))),
1667
- null)
1810
+ React.createElement('button', {
1811
+ type: 'button',
1812
+ 'data-polish-session-flag': menu.row.id,
1813
+ style: WS_MENU_ITEM,
1814
+ onClick: () => {
1815
+ const row = menu.row
1816
+ const current = markOf(marks, row.id)
1817
+ closeMenu()
1818
+ setMarks((prev) => withMark(prev, row.id, { flag: !current.flag }))
1819
+ },
1820
+ }, markOf(marks, menu.row.id).flag ? 'Remove flag' : 'Flag this session'),
1821
+ React.createElement('button', {
1822
+ type: 'button',
1823
+ 'data-polish-session-note': menu.row.id,
1824
+ style: WS_MENU_ITEM,
1825
+ onClick: () => {
1826
+ const row = menu.row
1827
+ const anchor = menu.anchor
1828
+ closeMenu()
1829
+ setMarkDraft(markOf(marks, row.id).note || '')
1830
+ setMenu({ kind: 'mark', row, anchor })
1831
+ },
1832
+ }, 'Session note…'))
1668
1833
  : null
1669
1834
 
1670
1835
  const switcherCard = switcher
@@ -1730,19 +1895,33 @@ window.__ModuleLoader__.load({
1730
1895
  style: WS_ADD,
1731
1896
  onClick: foldAll,
1732
1897
  }, allFolded ? '⌄' : '⌃'),
1898
+ React.createElement('button', {
1899
+ key: 'copy',
1900
+ type: 'button',
1901
+ 'data-polish-ws-copy': 'tree',
1902
+ title: 'Copy the tree as text',
1903
+ 'aria-label': 'Copy the tree as text',
1904
+ style: WS_ADD,
1905
+ onClick: () => {
1906
+ const text = treeText(rows, folderNames, (id) => markOf(marks, id))
1907
+ Promise.resolve(navigator.clipboard.writeText(text))
1908
+ .then(() => setNotice('Tree copied.'))
1909
+ .catch(() => setNotice('Could not copy the tree.'))
1910
+ },
1911
+ }, '⧉'),
1733
1912
  React.createElement('button', {
1734
1913
  key: 'density',
1735
1914
  type: 'button',
1736
1915
  'data-polish-ws-density': densityOf(density),
1737
- title: densityOf(density) === 'compact' ? 'Use comfortable rows' : 'Use compact rows',
1738
- 'aria-label': densityOf(density) === 'compact' ? 'Use comfortable rows' : 'Use compact rows',
1916
+ title: DENSITY_TITLES[densityOf(density)],
1917
+ 'aria-label': DENSITY_TITLES[densityOf(density)],
1739
1918
  style: WS_ADD,
1740
1919
  onClick: () => {
1741
- const next = densityOf(density) === 'compact' ? 'comfortable' : 'compact'
1920
+ const next = nextDensity(density)
1742
1921
  setDensity(next)
1743
1922
  writeDensity(next)
1744
1923
  },
1745
- }, densityOf(density) === 'compact' ? '≡' : '≣'),
1924
+ }, DENSITY_GLYPHS[densityOf(density)]),
1746
1925
  React.createElement('button', {
1747
1926
  key: 'add',
1748
1927
  type: 'button',
@@ -1839,8 +2018,14 @@ window.__ModuleLoader__.load({
1839
2018
  }),
1840
2019
  onClick: () => services.openSession(row.id),
1841
2020
  onContextMenu: (event) => openMenu(event, row, workspace),
1842
- onMouseEnter: () => setHover('session:' + row.id),
1843
- onMouseLeave: () => setHover(null),
2021
+ onMouseEnter: (event) => {
2022
+ setHover('session:' + row.id)
2023
+ const note = markOf(marks, row.id).note
2024
+ if (!note) return
2025
+ const box = event.currentTarget.getBoundingClientRect()
2026
+ setCard({ text: note, top: box.bottom + 6, left: Math.max(8, box.left) })
2027
+ },
2028
+ onMouseLeave: () => { setHover(null); setCard(null) },
1844
2029
  onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
1845
2030
  onDragOver: (event) => {
1846
2031
  if (!drag || drag.kind !== 'session' || drag.id === row.id) return
@@ -1852,7 +2037,14 @@ window.__ModuleLoader__.load({
1852
2037
  },
1853
2038
  row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1854
2039
  pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1855
- React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
2040
+ markOf(marks, row.id).flag
2041
+ ? React.createElement('span', { 'data-polish-mark-flag': row.id, style: WS_PIN, title: 'Flagged' }, '⚑')
2042
+ : null,
2043
+ React.createElement('span', {
2044
+ style: Object.assign({}, WS_TITLE, {
2045
+ fontStyle: markOf(marks, row.id).note ? 'italic' : undefined,
2046
+ }),
2047
+ }, row.displayTitle || row.id),
1856
2048
  rowHovered || rowFocused
1857
2049
  ? React.createElement('button', {
1858
2050
  type: 'button',
@@ -1990,7 +2182,7 @@ window.__ModuleLoader__.load({
1990
2182
  : null)))
1991
2183
 
1992
2184
  if (open) {
1993
- sessions.forEach((row) => {
2185
+ (metrics.sessions ? sessions : []).forEach((row) => {
1994
2186
  index += 1
1995
2187
  children.push(sessionRow(workspace, row, index))
1996
2188
  })
@@ -2108,6 +2300,7 @@ window.__ModuleLoader__.load({
2108
2300
  noteMenu,
2109
2301
  folderMenu,
2110
2302
  renameFolderMenu,
2303
+ markMenu,
2111
2304
  noteCard,
2112
2305
  archivedMenu,
2113
2306
  paintMenu,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.19.0",
3
+ "version": "0.21.2",
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",