@aryacode/dsh-aryacode-polish 0.13.1 → 0.15.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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.15.2] - 2026-10-28
4
+
5
+ ### Added
6
+ - **Density modes.** A control in the tree header switches between comfortable and
7
+ compact rows, and the choice is remembered (`aryacode-polish:density.v1`). Density
8
+ changes row height only — the text size stays readable.
9
+
10
+ ### Fixed
11
+ - **Expand all now expands folders too.** The header button only flipped workspace rows, so a
12
+ folded folder kept its workspaces hidden while the button claimed everything was open.
13
+ - **Archived sessions no longer sit in their workspace.** The tree now reads
14
+ `archivedSessionIds` (it never did) and shows those sessions in a collapsed
15
+ **Archived** section at the bottom, each one restorable from its own menu.
16
+
17
+ ## [0.14.0] - 2026-10-28
18
+
19
+ ### Added
20
+
21
+ - **Quick switcher.** Ctrl/⌘-K (or Cmd-K) opens a palette over everything: every session
22
+ in every workspace, ranked so a prefix beats a mention, arrows to walk, Enter to open,
23
+ Escape to dismiss. Rows show the session title and the workspace it lives in.
24
+ - **Folded state persists.** Which workspaces and which folders are collapsed is kept
25
+ under `aryacode-polish:folded.v1`, so reopening the app looks like it did on the way
26
+ out. A storage that refuses costs the memory and nothing else.
27
+
3
28
  ## [0.13.1] - 2026-10-28
4
29
 
5
30
  ### Added
@@ -202,7 +202,7 @@ const WS_ROW = {
202
202
  gap: '6px',
203
203
  boxSizing: 'border-box',
204
204
  width: '100%',
205
- height: '28px',
205
+ height: 'var(--polish-ws-row, 28px)',
206
206
  padding: '0 6px',
207
207
  border: 'none',
208
208
  borderRadius: 'var(--dsw-radius-sm, 6px)',
@@ -384,6 +384,183 @@ export function moveBefore(list, movingId, beforeId) {
384
384
 
385
385
  const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
386
386
  const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
387
+ const FOLD_KEY = 'aryacode-polish:folded.v1'
388
+ const DENSITY_KEY = 'aryacode-polish:density.v1'
389
+
390
+ export const DENSITIES = ['comfortable', 'compact']
391
+
392
+ /** Row height per density: the tree fits more rows without shrinking the text. */
393
+ export const DENSITY_METRICS = {
394
+ comfortable: { row: '28px' },
395
+ compact: { row: '22px' },
396
+ }
397
+
398
+ export function densityOf(value) {
399
+ return value === 'compact' ? 'compact' : 'comfortable'
400
+ }
401
+
402
+ export function readDensity(storage) {
403
+ try {
404
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
405
+ if (!store) return 'comfortable'
406
+ return densityOf(store.getItem(DENSITY_KEY))
407
+ } catch {
408
+ return 'comfortable'
409
+ }
410
+ }
411
+
412
+ export function writeDensity(value, storage) {
413
+ try {
414
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
415
+ if (!store) return
416
+ store.setItem(DENSITY_KEY, densityOf(value))
417
+ } catch {
418
+ /* remembering this is a nicety, not a requirement */
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Split the flat session list into the rows a workspace shows and the ones it has
424
+ * archived. The shipped browser hides archived sessions; here they keep a section of
425
+ * their own at the bottom instead of disappearing without a trace.
426
+ */
427
+ export function splitArchived(sessions, archivedIds) {
428
+ const archive = archivedIds instanceof Set ? archivedIds : new Set(archivedIds || [])
429
+ const active = []
430
+ const archived = []
431
+ for (const session of Array.isArray(sessions) ? sessions : []) {
432
+ if (archive.has(session.id)) archived.push(session)
433
+ else active.push(session)
434
+ }
435
+ return { active, archived }
436
+ }
437
+
438
+ const WS_SECTION = {
439
+ display: 'flex',
440
+ alignItems: 'center',
441
+ gap: '5px',
442
+ padding: '8px 6px 2px',
443
+ textTransform: 'uppercase',
444
+ letterSpacing: '0.04em',
445
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
446
+ fontSize: 'calc(' + 'var(--dsh-content-font-size-secondary, 13px)' + ' - 3px)',
447
+ cursor: 'pointer',
448
+ userSelect: 'none',
449
+ }
450
+
451
+ /** Which workspaces and folders are folded, kept so a restart looks like yesterday. */
452
+ export function readFolded(storage) {
453
+ try {
454
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
455
+ if (!store) return { workspaces: {}, folders: {} }
456
+ const raw = store.getItem(FOLD_KEY)
457
+ if (!raw) return { workspaces: {}, folders: {} }
458
+ const parsed = JSON.parse(raw)
459
+ return {
460
+ workspaces: (parsed && parsed.workspaces) || {},
461
+ folders: (parsed && parsed.folders) || {},
462
+ }
463
+ } catch {
464
+ return { workspaces: {}, folders: {} }
465
+ }
466
+ }
467
+
468
+ export function writeFolded(state, storage) {
469
+ try {
470
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
471
+ if (!store) return
472
+ const clean = {
473
+ workspaces: (state && state.workspaces) || {},
474
+ folders: (state && state.folders) || {},
475
+ }
476
+ store.setItem(FOLD_KEY, JSON.stringify(clean))
477
+ } catch {
478
+ /* folding is a convenience; a store that refuses must not break the tree */
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Rank the quick switcher's rows. A title that starts with the query wins over one
484
+ * that merely contains it, and equal kinds keep the order they arrived in — so
485
+ * typing "gre" puts Greeting above anything that mentions greeting later on.
486
+ */
487
+ export function rankMatches(items, query, text = (item) => String(item.title || item.id || '')) {
488
+ const list = Array.isArray(items) ? items : []
489
+ const needle = String(query || '').trim().toLowerCase()
490
+ if (!needle) return list.slice(0, 40)
491
+ const scored = []
492
+ list.forEach((item, order) => {
493
+ const hay = text(item).toLowerCase()
494
+ const at = hay.indexOf(needle)
495
+ if (at === -1) return
496
+ scored.push({ item, rank: at === 0 ? 0 : 1, order })
497
+ })
498
+ scored.sort((a, b) => (a.rank - b.rank) || (a.order - b.order))
499
+ return scored.slice(0, 40).map((entry) => entry.item)
500
+ }
501
+
502
+ const SWITCHER_BACKDROP = {
503
+ position: 'fixed',
504
+ inset: 0,
505
+ zIndex: 1300,
506
+ display: 'flex',
507
+ alignItems: 'flex-start',
508
+ justifyContent: 'center',
509
+ paddingTop: '12vh',
510
+ background: 'rgba(16, 18, 22, 0.28)',
511
+ }
512
+
513
+ const SWITCHER_CARD = {
514
+ width: 'min(560px, calc(100vw - 32px))',
515
+ maxHeight: '60vh',
516
+ overflow: 'hidden',
517
+ display: 'flex',
518
+ flexDirection: 'column',
519
+ borderRadius: 'var(--dsw-radius-lg, 10px)',
520
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
521
+ background: 'Canvas',
522
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
523
+ color: 'var(--dsw-alias-label-primary, #1b1d21)',
524
+ }
525
+
526
+ const SWITCHER_INPUT = {
527
+ border: 'none',
528
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
529
+ background: 'transparent',
530
+ color: 'inherit',
531
+ fontSize: '14px',
532
+ padding: '12px 14px',
533
+ outline: 'none',
534
+ }
535
+
536
+ const SWITCHER_LIST = {
537
+ overflowY: 'auto',
538
+ padding: '4px',
539
+ display: 'flex',
540
+ flexDirection: 'column',
541
+ gap: '1px',
542
+ }
543
+
544
+ const SWITCHER_ROW = {
545
+ display: 'flex',
546
+ alignItems: 'center',
547
+ gap: '8px',
548
+ padding: '7px 10px',
549
+ border: 'none',
550
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
551
+ background: 'transparent',
552
+ color: 'inherit',
553
+ fontSize: '13px',
554
+ textAlign: 'left',
555
+ cursor: 'pointer',
556
+ }
557
+
558
+ const SWITCHER_HINT = {
559
+ flex: 'none',
560
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
561
+ fontSize: '11px',
562
+ fontVariantNumeric: 'tabular-nums',
563
+ }
387
564
 
388
565
  /**
389
566
  * A workspace whose label carries a path ("Work/Client A") files itself under
@@ -586,7 +763,7 @@ export function BetterWorkspace(props) {
586
763
  const services = props.services || {}
587
764
  const { ctx, slots } = services
588
765
  const standard = { wide: props.wide === true, expandSidebar: props.expandSidebar }
589
- const [expanded, setExpanded] = React.useState({})
766
+ const [expanded, setExpanded] = React.useState(() => readFolded().workspaces)
590
767
  const [menu, setMenu] = React.useState(null)
591
768
  const [armed, setArmed] = React.useState(null)
592
769
  const [adding, setAdding] = React.useState(false)
@@ -597,7 +774,12 @@ export function BetterWorkspace(props) {
597
774
  const [drag, setDrag] = React.useState(null)
598
775
  const [dropping, setDropping] = React.useState(null)
599
776
  const [look, setLook] = React.useState(() => readAppearance())
600
- const [folders, setFolders] = React.useState({})
777
+ const folded = React.useMemo(() => readFolded(), [])
778
+ const [folders, setFolders] = React.useState(folded.folders)
779
+ const [switcher, setSwitcher] = React.useState(null)
780
+ const [density, setDensity] = React.useState(() => readDensity())
781
+ const [archiveOpen, setArchiveOpen] = React.useState(false)
782
+ React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
601
783
  const [renaming, setRenaming] = React.useState(null)
602
784
  const commitRename = async () => {
603
785
  const draft = renaming
@@ -632,7 +814,11 @@ export function BetterWorkspace(props) {
632
814
  () => new Set((workspaceState && workspaceState.pinnedSessionIds) || []),
633
815
  [workspaceState],
634
816
  )
635
- const allRows = workspaceRows(workspaceList, sessionList)
817
+ const metrics = DENSITY_METRICS[densityOf(density)]
818
+ const archiveSet = new Set((workspaceState && workspaceState.archivedSessionIds) || [])
819
+ const split = splitArchived(sessionList, archiveSet)
820
+ const allRows = workspaceRows(workspaceList, split.active)
821
+ const archivedSessions = split.archived
636
822
  const rows = filterTree(allRows, query, pinned)
637
823
  const folderOf = (workspace) => folderKey(workspace)
638
824
  const visibleRows = rows.filter((group) => {
@@ -645,7 +831,12 @@ export function BetterWorkspace(props) {
645
831
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
646
832
  const totalCount = allRows.reduce((sum, row) => sum + row.sessions.length, 0)
647
833
  const shownCount = rows.reduce((sum, row) => sum + row.sessions.length, 0)
648
- const allFolded = rows.length > 0 && rows.every(row => expanded[row.workspace.workspaceId] === false)
834
+ const folderNames = Array.from(new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean)))
835
+ // One click should always do the useful thing: if anything is folded, the button
836
+ // expands everything (folders included); only when nothing is folded does it fold.
837
+ const anyFolded = rows.some(row => expanded[row.workspace.workspaceId] === false)
838
+ || folderNames.some(name => folders[name] === true)
839
+ const allFolded = rows.length > 0 && !anyFolded
649
840
 
650
841
  React.useEffect(() => {
651
842
  if (cursor >= walk.length) setCursor(walk.length - 1)
@@ -664,6 +855,36 @@ export function BetterWorkspace(props) {
664
855
  return () => document.removeEventListener('keydown', onShortcut)
665
856
  }, [])
666
857
 
858
+ React.useEffect(() => {
859
+ const onOpen = (event) => {
860
+ const key = (event.key || '').toLowerCase()
861
+ if (!(event.ctrlKey || event.metaKey) || key !== 'k') return
862
+ event.preventDefault()
863
+ setSwitcher((prev) => (prev ? null : { query: '', index: 0 }))
864
+ }
865
+ const onEscape = (event) => {
866
+ if (event.key === 'Escape') setSwitcher(null)
867
+ }
868
+ document.addEventListener('keydown', onOpen)
869
+ document.addEventListener('keydown', onEscape)
870
+ return () => {
871
+ document.removeEventListener('keydown', onOpen)
872
+ document.removeEventListener('keydown', onEscape)
873
+ }
874
+ }, [])
875
+
876
+ const switchItems = React.useMemo(() => {
877
+ const items = []
878
+ allRows.forEach((group) => {
879
+ const workspace = workspaceLabel(group.workspace)
880
+ group.sessions.forEach((row) => {
881
+ items.push({ id: row.id, title: row.displayTitle || row.id, workspace })
882
+ })
883
+ })
884
+ return items
885
+ }, [allRows])
886
+ const switchHits = switcher ? rankMatches(switchItems, switcher.query) : []
887
+
667
888
  const openMenu = (event, row, workspace) => {
668
889
  event.preventDefault()
669
890
  event.stopPropagation()
@@ -685,10 +906,17 @@ export function BetterWorkspace(props) {
685
906
  })
686
907
  }
687
908
  const foldAll = () => {
688
- if (allFolded) { setExpanded({}); return }
909
+ if (anyFolded) {
910
+ setExpanded({})
911
+ setFolders({})
912
+ return
913
+ }
689
914
  const next = {}
690
915
  rows.forEach((row) => { next[row.workspace.workspaceId] = false })
916
+ const nextFolders = {}
917
+ folderNames.forEach((name) => { nextFolders[name] = true })
691
918
  setExpanded(next)
919
+ setFolders(nextFolders)
692
920
  }
693
921
 
694
922
  const reorderWorkspace = async (movingId, beforeId) => {
@@ -832,6 +1060,25 @@ export function BetterWorkspace(props) {
832
1060
  }, armed && armed === menu.workspace.workspaceId ? 'Remove workspace?' : 'Remove workspace'))
833
1061
  : null
834
1062
 
1063
+ const archivedMenu = menu && menu.kind === 'archived'
1064
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1065
+ React.createElement('button', {
1066
+ type: 'button',
1067
+ 'data-polish-ws-unarchive': menu.row.id,
1068
+ style: WS_MENU_ITEM,
1069
+ onClick: async () => {
1070
+ const row = menu.row
1071
+ closeMenu()
1072
+ try {
1073
+ await services.unarchiveSession(row.id)
1074
+ setNotice('Restored that session.')
1075
+ } catch {
1076
+ setNotice('Could not restore that session.')
1077
+ }
1078
+ },
1079
+ }, 'Unarchive session'))
1080
+ : null
1081
+
835
1082
  const paintMenu = menu && menu.kind === 'paint'
836
1083
  ? (() => {
837
1084
  const workspace = menu.workspace
@@ -882,6 +1129,56 @@ export function BetterWorkspace(props) {
882
1129
  null)
883
1130
  : null
884
1131
 
1132
+ const switcherCard = switcher
1133
+ ? ReactDOM.createPortal(React.createElement('div', {
1134
+ 'data-polish-switcher': 'open',
1135
+ style: SWITCHER_BACKDROP,
1136
+ onPointerDown: (event) => { if (event.target === event.currentTarget) setSwitcher(null) },
1137
+ },
1138
+ React.createElement('div', { style: SWITCHER_CARD },
1139
+ React.createElement('input', {
1140
+ 'data-polish-switcher-input': 'true',
1141
+ autoFocus: true,
1142
+ value: switcher.query,
1143
+ placeholder: 'Jump to a session…',
1144
+ 'aria-label': 'Jump to a session',
1145
+ style: SWITCHER_INPUT,
1146
+ onChange: (event) => setSwitcher(Object.assign({}, switcher, { query: event.target.value, index: 0 })),
1147
+ onKeyDown: (event) => {
1148
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1149
+ if (switchHits.length === 0) return
1150
+ event.preventDefault()
1151
+ const step = event.key === 'ArrowDown' ? 1 : -1
1152
+ const next = (switcher.index + step + switchHits.length) % switchHits.length
1153
+ setSwitcher(Object.assign({}, switcher, { index: next }))
1154
+ return
1155
+ }
1156
+ if (event.key === 'Enter') {
1157
+ const hit = switchHits[switcher.index]
1158
+ if (!hit) return
1159
+ event.preventDefault()
1160
+ setSwitcher(null)
1161
+ services.openSession(hit.id)
1162
+ }
1163
+ },
1164
+ }),
1165
+ React.createElement('div', { 'data-polish-switcher-list': 'true', style: SWITCHER_LIST },
1166
+ switchHits.length === 0
1167
+ ? React.createElement('div', { style: Object.assign({}, SWITCHER_ROW, { cursor: 'default' }) }, 'No session matches that.')
1168
+ : switchHits.map((hit, at) => React.createElement('button', {
1169
+ key: hit.id,
1170
+ type: 'button',
1171
+ 'data-polish-switcher-row': hit.id,
1172
+ style: Object.assign({}, SWITCHER_ROW, {
1173
+ background: at === switcher.index ? 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' : 'transparent',
1174
+ }),
1175
+ onPointerDown: (event) => event.preventDefault(),
1176
+ onClick: () => { setSwitcher(null); services.openSession(hit.id) },
1177
+ },
1178
+ React.createElement('span', { style: WS_TITLE }, hit.title),
1179
+ React.createElement('span', { style: SWITCHER_HINT }, hit.workspace)))))), document.body)
1180
+ : null
1181
+
885
1182
  const children = []
886
1183
 
887
1184
  children.push(React.createElement('div', { key: 'header', 'data-polish-ws': 'header', style: WS_HEADER },
@@ -895,6 +1192,19 @@ export function BetterWorkspace(props) {
895
1192
  style: WS_ADD,
896
1193
  onClick: foldAll,
897
1194
  }, allFolded ? '⌄' : '⌃'),
1195
+ React.createElement('button', {
1196
+ key: 'density',
1197
+ type: 'button',
1198
+ 'data-polish-ws-density': densityOf(density),
1199
+ title: densityOf(density) === 'compact' ? 'Use comfortable rows' : 'Use compact rows',
1200
+ 'aria-label': densityOf(density) === 'compact' ? 'Use comfortable rows' : 'Use compact rows',
1201
+ style: WS_ADD,
1202
+ onClick: () => {
1203
+ const next = densityOf(density) === 'compact' ? 'comfortable' : 'compact'
1204
+ setDensity(next)
1205
+ writeDensity(next)
1206
+ },
1207
+ }, densityOf(density) === 'compact' ? '≡' : '≣'),
898
1208
  React.createElement('button', {
899
1209
  key: 'add',
900
1210
  type: 'button',
@@ -1103,6 +1413,36 @@ export function BetterWorkspace(props) {
1103
1413
  }
1104
1414
  })
1105
1415
 
1416
+ if (archivedSessions.length > 0) {
1417
+ children.push(React.createElement('div', {
1418
+ key: 'archived-head',
1419
+ 'data-polish-ws-archived': 'head',
1420
+ style: WS_SECTION,
1421
+ onClick: () => setArchiveOpen((open) => !open),
1422
+ },
1423
+ React.createElement('span', { 'aria-hidden': 'true' }, archiveOpen ? '⌄' : '›'),
1424
+ React.createElement('span', { style: WS_TITLE }, 'Archived'),
1425
+ React.createElement('span', { style: WS_COUNT }, String(archivedSessions.length))))
1426
+
1427
+ if (archiveOpen) {
1428
+ for (const row of archivedSessions) {
1429
+ children.push(React.createElement('div', {
1430
+ key: 'archived:' + row.id,
1431
+ 'data-polish-ws-archived-row': row.id,
1432
+ title: 'Archived session',
1433
+ style: Object.assign({}, WS_ROW, { opacity: 0.7, fontStyle: 'italic' }),
1434
+ onClick: () => services.openSession(row.id),
1435
+ onContextMenu: (event) => {
1436
+ event.preventDefault()
1437
+ event.stopPropagation()
1438
+ setMenu({ kind: 'archived', row, anchor: { top: event.clientY, left: event.clientX } })
1439
+ },
1440
+ },
1441
+ React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id)))
1442
+ }
1443
+ }
1444
+ }
1445
+
1106
1446
  return React.createElement('div', {
1107
1447
  'data-polish-ws': 'tree',
1108
1448
  tabIndex: 0,
@@ -1119,13 +1459,16 @@ export function BetterWorkspace(props) {
1119
1459
  overflowX: 'hidden',
1120
1460
  padding: '2px 6px 12px',
1121
1461
  outline: 'none',
1462
+ '--polish-ws-row': metrics.row,
1122
1463
  },
1123
1464
  },
1124
1465
  children,
1125
1466
  shownCount < totalCount
1126
1467
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
1127
1468
  : null,
1469
+ switcherCard,
1128
1470
  sessionMenu,
1471
+ archivedMenu,
1129
1472
  paintMenu,
1130
1473
  workspaceMenu)
1131
1474
  }
@@ -1252,6 +1595,7 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
1252
1595
  reorderSession: (workspaceId, movingId, beforeId) => seated.workspaces.insertSessionBefore(workspaceId, movingId, beforeId),
1253
1596
  removeWorkspace: (id) => seated.workspaces.delete(id),
1254
1597
  renameWorkspace: (id, title) => seated.workspaces.rename(id, title),
1598
+ unarchiveSession: (id) => seated.workspaces.unarchiveSession(id),
1255
1599
  openFolder: async (path) => {
1256
1600
  const answer = await seated.remote.session.openWorkspacePath({ path })
1257
1601
  if (answer && answer.ok === false) throw new Error('cannot open the folder')
@@ -134,3 +134,45 @@ offers exactly one reorder verb (`insertBefore(movingId, beforeId)`), so restori
134
134
  one move per workspace that sits in the wrong slot. Ids the Host no longer has are
135
135
  filtered out first, so a preset that names a deleted workspace cannot shuffle the
136
136
  survivors into the wrong places.
137
+
138
+ ## Quick switcher
139
+
140
+ Ctrl/⌘-K opens a palette over every session in every workspace: the list is
141
+ `rankMatches(allSessions, query)`, which scores a title that *starts* with the query
142
+ above one that merely mentions it and keeps arrival order inside a rank (so "gre" puts
143
+ Greeting above later mentions). Arrows walk, Enter opens the session through
144
+ `uiWorkspace.openSession`, Escape or a backdrop click dismisses. It is portalled to
145
+ `document.body` with its own backdrop, so neither the sidebar's scroll container nor a
146
+ panel's stacking context can clip it.
147
+
148
+ ## Folded state
149
+
150
+ `expanded` (workspaces) and `folders` (title folders) are seeded from
151
+ `aryacode-polish:folded.v1` and written back whenever either changes, so a restart looks
152
+ like the way out. Verified across a real restart: folding `WORK` left
153
+ `{"workspaces":{},"folders":{"Work":true}}` in storage and the folder still read `› WORK 2`
154
+ with its workspace hidden.
155
+
156
+ ## Density
157
+
158
+ The header carries a density control that flips between comfortable (28 px rows) and
159
+ compact (22 px rows), remembered under `aryacode-polish:density.v1`. Density changes row
160
+ height only: the text keeps its size, so a compact tree is still readable on a laptop.
161
+ The row height travels through the CSS variable `--polish-ws-row`, which the tree element
162
+ sets from `DENSITY_METRICS`; nothing else in the row styling depends on the mode.
163
+
164
+ ## Archived sessions
165
+
166
+ Archiving a session removes it from its workspace — that is what the Host means by
167
+ archived, and the shipped browser simply stops rendering it. Here those sessions keep a
168
+ collapsed **Archived** section at the bottom of the tree instead, dimmed and italic, with
169
+ their count on the header. Clicking one opens it; right-clicking offers **Unarchive
170
+ session**, which goes through `workspaces.unarchiveSession`. This also fixed a real gap:
171
+ before this, `workspaceRows` never consulted `archivedSessionIds`, so archived sessions
172
+ were being rendered inline in their workspace as if they were live.
173
+
174
+ ## Folding everything
175
+
176
+ The header's fold control expands when anything is folded (folders included) and folds
177
+ only when nothing is. An earlier version only looked at workspace rows, so a folded
178
+ folder left the button saying "Expand all" while its workspaces stayed hidden.
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.13.1"
56
+ const CLIENT_VERSION = "0.15.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. */
@@ -403,7 +403,7 @@ window.__ModuleLoader__.load({
403
403
  gap: '6px',
404
404
  boxSizing: 'border-box',
405
405
  width: '100%',
406
- height: '28px',
406
+ height: 'var(--polish-ws-row, 28px)',
407
407
  padding: '0 6px',
408
408
  border: 'none',
409
409
  borderRadius: 'var(--dsw-radius-sm, 6px)',
@@ -585,6 +585,183 @@ window.__ModuleLoader__.load({
585
585
 
586
586
  const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
587
587
  const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
588
+ const FOLD_KEY = 'aryacode-polish:folded.v1'
589
+ const DENSITY_KEY = 'aryacode-polish:density.v1'
590
+
591
+ const DENSITIES = ['comfortable', 'compact']
592
+
593
+ /** Row height per density: the tree fits more rows without shrinking the text. */
594
+ const DENSITY_METRICS = {
595
+ comfortable: { row: '28px' },
596
+ compact: { row: '22px' },
597
+ }
598
+
599
+ function densityOf(value) {
600
+ return value === 'compact' ? 'compact' : 'comfortable'
601
+ }
602
+
603
+ function readDensity(storage) {
604
+ try {
605
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
606
+ if (!store) return 'comfortable'
607
+ return densityOf(store.getItem(DENSITY_KEY))
608
+ } catch {
609
+ return 'comfortable'
610
+ }
611
+ }
612
+
613
+ function writeDensity(value, storage) {
614
+ try {
615
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
616
+ if (!store) return
617
+ store.setItem(DENSITY_KEY, densityOf(value))
618
+ } catch {
619
+ /* remembering this is a nicety, not a requirement */
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Split the flat session list into the rows a workspace shows and the ones it has
625
+ * archived. The shipped browser hides archived sessions; here they keep a section of
626
+ * their own at the bottom instead of disappearing without a trace.
627
+ */
628
+ function splitArchived(sessions, archivedIds) {
629
+ const archive = archivedIds instanceof Set ? archivedIds : new Set(archivedIds || [])
630
+ const active = []
631
+ const archived = []
632
+ for (const session of Array.isArray(sessions) ? sessions : []) {
633
+ if (archive.has(session.id)) archived.push(session)
634
+ else active.push(session)
635
+ }
636
+ return { active, archived }
637
+ }
638
+
639
+ const WS_SECTION = {
640
+ display: 'flex',
641
+ alignItems: 'center',
642
+ gap: '5px',
643
+ padding: '8px 6px 2px',
644
+ textTransform: 'uppercase',
645
+ letterSpacing: '0.04em',
646
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
647
+ fontSize: 'calc(' + 'var(--dsh-content-font-size-secondary, 13px)' + ' - 3px)',
648
+ cursor: 'pointer',
649
+ userSelect: 'none',
650
+ }
651
+
652
+ /** Which workspaces and folders are folded, kept so a restart looks like yesterday. */
653
+ function readFolded(storage) {
654
+ try {
655
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
656
+ if (!store) return { workspaces: {}, folders: {} }
657
+ const raw = store.getItem(FOLD_KEY)
658
+ if (!raw) return { workspaces: {}, folders: {} }
659
+ const parsed = JSON.parse(raw)
660
+ return {
661
+ workspaces: (parsed && parsed.workspaces) || {},
662
+ folders: (parsed && parsed.folders) || {},
663
+ }
664
+ } catch {
665
+ return { workspaces: {}, folders: {} }
666
+ }
667
+ }
668
+
669
+ function writeFolded(state, storage) {
670
+ try {
671
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
672
+ if (!store) return
673
+ const clean = {
674
+ workspaces: (state && state.workspaces) || {},
675
+ folders: (state && state.folders) || {},
676
+ }
677
+ store.setItem(FOLD_KEY, JSON.stringify(clean))
678
+ } catch {
679
+ /* folding is a convenience; a store that refuses must not break the tree */
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Rank the quick switcher's rows. A title that starts with the query wins over one
685
+ * that merely contains it, and equal kinds keep the order they arrived in — so
686
+ * typing "gre" puts Greeting above anything that mentions greeting later on.
687
+ */
688
+ function rankMatches(items, query, text = (item) => String(item.title || item.id || '')) {
689
+ const list = Array.isArray(items) ? items : []
690
+ const needle = String(query || '').trim().toLowerCase()
691
+ if (!needle) return list.slice(0, 40)
692
+ const scored = []
693
+ list.forEach((item, order) => {
694
+ const hay = text(item).toLowerCase()
695
+ const at = hay.indexOf(needle)
696
+ if (at === -1) return
697
+ scored.push({ item, rank: at === 0 ? 0 : 1, order })
698
+ })
699
+ scored.sort((a, b) => (a.rank - b.rank) || (a.order - b.order))
700
+ return scored.slice(0, 40).map((entry) => entry.item)
701
+ }
702
+
703
+ const SWITCHER_BACKDROP = {
704
+ position: 'fixed',
705
+ inset: 0,
706
+ zIndex: 1300,
707
+ display: 'flex',
708
+ alignItems: 'flex-start',
709
+ justifyContent: 'center',
710
+ paddingTop: '12vh',
711
+ background: 'rgba(16, 18, 22, 0.28)',
712
+ }
713
+
714
+ const SWITCHER_CARD = {
715
+ width: 'min(560px, calc(100vw - 32px))',
716
+ maxHeight: '60vh',
717
+ overflow: 'hidden',
718
+ display: 'flex',
719
+ flexDirection: 'column',
720
+ borderRadius: 'var(--dsw-radius-lg, 10px)',
721
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
722
+ background: 'Canvas',
723
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
724
+ color: 'var(--dsw-alias-label-primary, #1b1d21)',
725
+ }
726
+
727
+ const SWITCHER_INPUT = {
728
+ border: 'none',
729
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
730
+ background: 'transparent',
731
+ color: 'inherit',
732
+ fontSize: '14px',
733
+ padding: '12px 14px',
734
+ outline: 'none',
735
+ }
736
+
737
+ const SWITCHER_LIST = {
738
+ overflowY: 'auto',
739
+ padding: '4px',
740
+ display: 'flex',
741
+ flexDirection: 'column',
742
+ gap: '1px',
743
+ }
744
+
745
+ const SWITCHER_ROW = {
746
+ display: 'flex',
747
+ alignItems: 'center',
748
+ gap: '8px',
749
+ padding: '7px 10px',
750
+ border: 'none',
751
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
752
+ background: 'transparent',
753
+ color: 'inherit',
754
+ fontSize: '13px',
755
+ textAlign: 'left',
756
+ cursor: 'pointer',
757
+ }
758
+
759
+ const SWITCHER_HINT = {
760
+ flex: 'none',
761
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
762
+ fontSize: '11px',
763
+ fontVariantNumeric: 'tabular-nums',
764
+ }
588
765
 
589
766
  /**
590
767
  * A workspace whose label carries a path ("Work/Client A") files itself under
@@ -787,7 +964,7 @@ window.__ModuleLoader__.load({
787
964
  const services = props.services || {}
788
965
  const { ctx, slots } = services
789
966
  const standard = { wide: props.wide === true, expandSidebar: props.expandSidebar }
790
- const [expanded, setExpanded] = React.useState({})
967
+ const [expanded, setExpanded] = React.useState(() => readFolded().workspaces)
791
968
  const [menu, setMenu] = React.useState(null)
792
969
  const [armed, setArmed] = React.useState(null)
793
970
  const [adding, setAdding] = React.useState(false)
@@ -798,7 +975,12 @@ window.__ModuleLoader__.load({
798
975
  const [drag, setDrag] = React.useState(null)
799
976
  const [dropping, setDropping] = React.useState(null)
800
977
  const [look, setLook] = React.useState(() => readAppearance())
801
- const [folders, setFolders] = React.useState({})
978
+ const folded = React.useMemo(() => readFolded(), [])
979
+ const [folders, setFolders] = React.useState(folded.folders)
980
+ const [switcher, setSwitcher] = React.useState(null)
981
+ const [density, setDensity] = React.useState(() => readDensity())
982
+ const [archiveOpen, setArchiveOpen] = React.useState(false)
983
+ React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
802
984
  const [renaming, setRenaming] = React.useState(null)
803
985
  const commitRename = async () => {
804
986
  const draft = renaming
@@ -833,7 +1015,11 @@ window.__ModuleLoader__.load({
833
1015
  () => new Set((workspaceState && workspaceState.pinnedSessionIds) || []),
834
1016
  [workspaceState],
835
1017
  )
836
- const allRows = workspaceRows(workspaceList, sessionList)
1018
+ const metrics = DENSITY_METRICS[densityOf(density)]
1019
+ const archiveSet = new Set((workspaceState && workspaceState.archivedSessionIds) || [])
1020
+ const split = splitArchived(sessionList, archiveSet)
1021
+ const allRows = workspaceRows(workspaceList, split.active)
1022
+ const archivedSessions = split.archived
837
1023
  const rows = filterTree(allRows, query, pinned)
838
1024
  const folderOf = (workspace) => folderKey(workspace)
839
1025
  const visibleRows = rows.filter((group) => {
@@ -846,7 +1032,12 @@ window.__ModuleLoader__.load({
846
1032
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
847
1033
  const totalCount = allRows.reduce((sum, row) => sum + row.sessions.length, 0)
848
1034
  const shownCount = rows.reduce((sum, row) => sum + row.sessions.length, 0)
849
- const allFolded = rows.length > 0 && rows.every(row => expanded[row.workspace.workspaceId] === false)
1035
+ const folderNames = Array.from(new Set(rows.map(row => folderKey(row.workspace)).filter(Boolean)))
1036
+ // One click should always do the useful thing: if anything is folded, the button
1037
+ // expands everything (folders included); only when nothing is folded does it fold.
1038
+ const anyFolded = rows.some(row => expanded[row.workspace.workspaceId] === false)
1039
+ || folderNames.some(name => folders[name] === true)
1040
+ const allFolded = rows.length > 0 && !anyFolded
850
1041
 
851
1042
  React.useEffect(() => {
852
1043
  if (cursor >= walk.length) setCursor(walk.length - 1)
@@ -865,6 +1056,36 @@ window.__ModuleLoader__.load({
865
1056
  return () => document.removeEventListener('keydown', onShortcut)
866
1057
  }, [])
867
1058
 
1059
+ React.useEffect(() => {
1060
+ const onOpen = (event) => {
1061
+ const key = (event.key || '').toLowerCase()
1062
+ if (!(event.ctrlKey || event.metaKey) || key !== 'k') return
1063
+ event.preventDefault()
1064
+ setSwitcher((prev) => (prev ? null : { query: '', index: 0 }))
1065
+ }
1066
+ const onEscape = (event) => {
1067
+ if (event.key === 'Escape') setSwitcher(null)
1068
+ }
1069
+ document.addEventListener('keydown', onOpen)
1070
+ document.addEventListener('keydown', onEscape)
1071
+ return () => {
1072
+ document.removeEventListener('keydown', onOpen)
1073
+ document.removeEventListener('keydown', onEscape)
1074
+ }
1075
+ }, [])
1076
+
1077
+ const switchItems = React.useMemo(() => {
1078
+ const items = []
1079
+ allRows.forEach((group) => {
1080
+ const workspace = workspaceLabel(group.workspace)
1081
+ group.sessions.forEach((row) => {
1082
+ items.push({ id: row.id, title: row.displayTitle || row.id, workspace })
1083
+ })
1084
+ })
1085
+ return items
1086
+ }, [allRows])
1087
+ const switchHits = switcher ? rankMatches(switchItems, switcher.query) : []
1088
+
868
1089
  const openMenu = (event, row, workspace) => {
869
1090
  event.preventDefault()
870
1091
  event.stopPropagation()
@@ -886,10 +1107,17 @@ window.__ModuleLoader__.load({
886
1107
  })
887
1108
  }
888
1109
  const foldAll = () => {
889
- if (allFolded) { setExpanded({}); return }
1110
+ if (anyFolded) {
1111
+ setExpanded({})
1112
+ setFolders({})
1113
+ return
1114
+ }
890
1115
  const next = {}
891
1116
  rows.forEach((row) => { next[row.workspace.workspaceId] = false })
1117
+ const nextFolders = {}
1118
+ folderNames.forEach((name) => { nextFolders[name] = true })
892
1119
  setExpanded(next)
1120
+ setFolders(nextFolders)
893
1121
  }
894
1122
 
895
1123
  const reorderWorkspace = async (movingId, beforeId) => {
@@ -1033,6 +1261,25 @@ window.__ModuleLoader__.load({
1033
1261
  }, armed && armed === menu.workspace.workspaceId ? 'Remove workspace?' : 'Remove workspace'))
1034
1262
  : null
1035
1263
 
1264
+ const archivedMenu = menu && menu.kind === 'archived'
1265
+ ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1266
+ React.createElement('button', {
1267
+ type: 'button',
1268
+ 'data-polish-ws-unarchive': menu.row.id,
1269
+ style: WS_MENU_ITEM,
1270
+ onClick: async () => {
1271
+ const row = menu.row
1272
+ closeMenu()
1273
+ try {
1274
+ await services.unarchiveSession(row.id)
1275
+ setNotice('Restored that session.')
1276
+ } catch {
1277
+ setNotice('Could not restore that session.')
1278
+ }
1279
+ },
1280
+ }, 'Unarchive session'))
1281
+ : null
1282
+
1036
1283
  const paintMenu = menu && menu.kind === 'paint'
1037
1284
  ? (() => {
1038
1285
  const workspace = menu.workspace
@@ -1083,6 +1330,56 @@ window.__ModuleLoader__.load({
1083
1330
  null)
1084
1331
  : null
1085
1332
 
1333
+ const switcherCard = switcher
1334
+ ? ReactDOM.createPortal(React.createElement('div', {
1335
+ 'data-polish-switcher': 'open',
1336
+ style: SWITCHER_BACKDROP,
1337
+ onPointerDown: (event) => { if (event.target === event.currentTarget) setSwitcher(null) },
1338
+ },
1339
+ React.createElement('div', { style: SWITCHER_CARD },
1340
+ React.createElement('input', {
1341
+ 'data-polish-switcher-input': 'true',
1342
+ autoFocus: true,
1343
+ value: switcher.query,
1344
+ placeholder: 'Jump to a session…',
1345
+ 'aria-label': 'Jump to a session',
1346
+ style: SWITCHER_INPUT,
1347
+ onChange: (event) => setSwitcher(Object.assign({}, switcher, { query: event.target.value, index: 0 })),
1348
+ onKeyDown: (event) => {
1349
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1350
+ if (switchHits.length === 0) return
1351
+ event.preventDefault()
1352
+ const step = event.key === 'ArrowDown' ? 1 : -1
1353
+ const next = (switcher.index + step + switchHits.length) % switchHits.length
1354
+ setSwitcher(Object.assign({}, switcher, { index: next }))
1355
+ return
1356
+ }
1357
+ if (event.key === 'Enter') {
1358
+ const hit = switchHits[switcher.index]
1359
+ if (!hit) return
1360
+ event.preventDefault()
1361
+ setSwitcher(null)
1362
+ services.openSession(hit.id)
1363
+ }
1364
+ },
1365
+ }),
1366
+ React.createElement('div', { 'data-polish-switcher-list': 'true', style: SWITCHER_LIST },
1367
+ switchHits.length === 0
1368
+ ? React.createElement('div', { style: Object.assign({}, SWITCHER_ROW, { cursor: 'default' }) }, 'No session matches that.')
1369
+ : switchHits.map((hit, at) => React.createElement('button', {
1370
+ key: hit.id,
1371
+ type: 'button',
1372
+ 'data-polish-switcher-row': hit.id,
1373
+ style: Object.assign({}, SWITCHER_ROW, {
1374
+ background: at === switcher.index ? 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' : 'transparent',
1375
+ }),
1376
+ onPointerDown: (event) => event.preventDefault(),
1377
+ onClick: () => { setSwitcher(null); services.openSession(hit.id) },
1378
+ },
1379
+ React.createElement('span', { style: WS_TITLE }, hit.title),
1380
+ React.createElement('span', { style: SWITCHER_HINT }, hit.workspace)))))), document.body)
1381
+ : null
1382
+
1086
1383
  const children = []
1087
1384
 
1088
1385
  children.push(React.createElement('div', { key: 'header', 'data-polish-ws': 'header', style: WS_HEADER },
@@ -1096,6 +1393,19 @@ window.__ModuleLoader__.load({
1096
1393
  style: WS_ADD,
1097
1394
  onClick: foldAll,
1098
1395
  }, allFolded ? '⌄' : '⌃'),
1396
+ React.createElement('button', {
1397
+ key: 'density',
1398
+ type: 'button',
1399
+ 'data-polish-ws-density': densityOf(density),
1400
+ title: densityOf(density) === 'compact' ? 'Use comfortable rows' : 'Use compact rows',
1401
+ 'aria-label': densityOf(density) === 'compact' ? 'Use comfortable rows' : 'Use compact rows',
1402
+ style: WS_ADD,
1403
+ onClick: () => {
1404
+ const next = densityOf(density) === 'compact' ? 'comfortable' : 'compact'
1405
+ setDensity(next)
1406
+ writeDensity(next)
1407
+ },
1408
+ }, densityOf(density) === 'compact' ? '≡' : '≣'),
1099
1409
  React.createElement('button', {
1100
1410
  key: 'add',
1101
1411
  type: 'button',
@@ -1304,6 +1614,36 @@ window.__ModuleLoader__.load({
1304
1614
  }
1305
1615
  })
1306
1616
 
1617
+ if (archivedSessions.length > 0) {
1618
+ children.push(React.createElement('div', {
1619
+ key: 'archived-head',
1620
+ 'data-polish-ws-archived': 'head',
1621
+ style: WS_SECTION,
1622
+ onClick: () => setArchiveOpen((open) => !open),
1623
+ },
1624
+ React.createElement('span', { 'aria-hidden': 'true' }, archiveOpen ? '⌄' : '›'),
1625
+ React.createElement('span', { style: WS_TITLE }, 'Archived'),
1626
+ React.createElement('span', { style: WS_COUNT }, String(archivedSessions.length))))
1627
+
1628
+ if (archiveOpen) {
1629
+ for (const row of archivedSessions) {
1630
+ children.push(React.createElement('div', {
1631
+ key: 'archived:' + row.id,
1632
+ 'data-polish-ws-archived-row': row.id,
1633
+ title: 'Archived session',
1634
+ style: Object.assign({}, WS_ROW, { opacity: 0.7, fontStyle: 'italic' }),
1635
+ onClick: () => services.openSession(row.id),
1636
+ onContextMenu: (event) => {
1637
+ event.preventDefault()
1638
+ event.stopPropagation()
1639
+ setMenu({ kind: 'archived', row, anchor: { top: event.clientY, left: event.clientX } })
1640
+ },
1641
+ },
1642
+ React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id)))
1643
+ }
1644
+ }
1645
+ }
1646
+
1307
1647
  return React.createElement('div', {
1308
1648
  'data-polish-ws': 'tree',
1309
1649
  tabIndex: 0,
@@ -1320,13 +1660,16 @@ window.__ModuleLoader__.load({
1320
1660
  overflowX: 'hidden',
1321
1661
  padding: '2px 6px 12px',
1322
1662
  outline: 'none',
1663
+ '--polish-ws-row': metrics.row,
1323
1664
  },
1324
1665
  },
1325
1666
  children,
1326
1667
  shownCount < totalCount
1327
1668
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
1328
1669
  : null,
1670
+ switcherCard,
1329
1671
  sessionMenu,
1672
+ archivedMenu,
1330
1673
  paintMenu,
1331
1674
  workspaceMenu)
1332
1675
  }
@@ -1453,6 +1796,7 @@ window.__ModuleLoader__.load({
1453
1796
  reorderSession: (workspaceId, movingId, beforeId) => seated.workspaces.insertSessionBefore(workspaceId, movingId, beforeId),
1454
1797
  removeWorkspace: (id) => seated.workspaces.delete(id),
1455
1798
  renameWorkspace: (id, title) => seated.workspaces.rename(id, title),
1799
+ unarchiveSession: (id) => seated.workspaces.unarchiveSession(id),
1456
1800
  openFolder: async (path) => {
1457
1801
  const answer = await seated.remote.session.openWorkspacePath({ path })
1458
1802
  if (answer && answer.ok === false) throw new Error('cannot open the folder')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.13.1",
3
+ "version": "0.15.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",