@aryacode/dsh-aryacode-polish 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0] - 2026-10-28
4
+
5
+ ### Added
6
+
7
+ - **Quick switcher.** Ctrl/⌘-K (or Cmd-K) opens a palette over everything: every session
8
+ in every workspace, ranked so a prefix beats a mention, arrows to walk, Enter to open,
9
+ Escape to dismiss. Rows show the session title and the workspace it lives in.
10
+ - **Folded state persists.** Which workspaces and which folders are collapsed is kept
11
+ under `aryacode-polish:folded.v1`, so reopening the app looks like it did on the way
12
+ out. A storage that refuses costs the memory and nothing else.
13
+
14
+ ## [0.13.1] - 2026-10-28
15
+
16
+ ### Added
17
+
18
+ - **Rename a workspace in place.** The workspace menu opens an input under the search
19
+ box; Enter (or blurring) sends `workspaces.rename`, Escape abandons it. Renaming to
20
+ `Work/Client A` is also how a workspace joins a folder.
21
+
22
+ ### Fixed
23
+
24
+ - `planReorder` ignores ids the Host no longer has, so a saved order that names a
25
+ deleted workspace cannot shuffle the survivors into the wrong slots.
26
+
27
+ ## [0.13.0] - 2026-10-28
28
+
29
+ ### Added
30
+
31
+ - **Folders from titles.** A workspace named `Work/Client A` files itself under a
32
+ collapsible `Work` row, with the folder's session count on the right. The grouping is
33
+ derived from the label and written nowhere, so renaming the workspace moves it.
34
+ - **Order presets.** The workspace menu can "Remember this order" and later "Restore
35
+ saved order", which replays the Host's only reorder verb — `insertBefore` — through a
36
+ pure `planReorder(current, wanted)` plan, stored under
37
+ `aryacode-polish:workspace-order.v1`.
38
+
3
39
  ## [0.12.1] - 2026-10-28
4
40
 
5
41
  ### Fixed
@@ -383,6 +383,194 @@ export function moveBefore(list, movingId, beforeId) {
383
383
  }
384
384
 
385
385
  const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
386
+ const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
387
+ const FOLD_KEY = 'aryacode-polish:folded.v1'
388
+
389
+ /** Which workspaces and folders are folded, kept so a restart looks like yesterday. */
390
+ export function readFolded(storage) {
391
+ try {
392
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
393
+ if (!store) return { workspaces: {}, folders: {} }
394
+ const raw = store.getItem(FOLD_KEY)
395
+ if (!raw) return { workspaces: {}, folders: {} }
396
+ const parsed = JSON.parse(raw)
397
+ return {
398
+ workspaces: (parsed && parsed.workspaces) || {},
399
+ folders: (parsed && parsed.folders) || {},
400
+ }
401
+ } catch {
402
+ return { workspaces: {}, folders: {} }
403
+ }
404
+ }
405
+
406
+ export function writeFolded(state, storage) {
407
+ try {
408
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
409
+ if (!store) return
410
+ const clean = {
411
+ workspaces: (state && state.workspaces) || {},
412
+ folders: (state && state.folders) || {},
413
+ }
414
+ store.setItem(FOLD_KEY, JSON.stringify(clean))
415
+ } catch {
416
+ /* folding is a convenience; a store that refuses must not break the tree */
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Rank the quick switcher's rows. A title that starts with the query wins over one
422
+ * that merely contains it, and equal kinds keep the order they arrived in — so
423
+ * typing "gre" puts Greeting above anything that mentions greeting later on.
424
+ */
425
+ export function rankMatches(items, query, text = (item) => String(item.title || item.id || '')) {
426
+ const list = Array.isArray(items) ? items : []
427
+ const needle = String(query || '').trim().toLowerCase()
428
+ if (!needle) return list.slice(0, 40)
429
+ const scored = []
430
+ list.forEach((item, order) => {
431
+ const hay = text(item).toLowerCase()
432
+ const at = hay.indexOf(needle)
433
+ if (at === -1) return
434
+ scored.push({ item, rank: at === 0 ? 0 : 1, order })
435
+ })
436
+ scored.sort((a, b) => (a.rank - b.rank) || (a.order - b.order))
437
+ return scored.slice(0, 40).map((entry) => entry.item)
438
+ }
439
+
440
+ const SWITCHER_BACKDROP = {
441
+ position: 'fixed',
442
+ inset: 0,
443
+ zIndex: 1300,
444
+ display: 'flex',
445
+ alignItems: 'flex-start',
446
+ justifyContent: 'center',
447
+ paddingTop: '12vh',
448
+ background: 'rgba(16, 18, 22, 0.28)',
449
+ }
450
+
451
+ const SWITCHER_CARD = {
452
+ width: 'min(560px, calc(100vw - 32px))',
453
+ maxHeight: '60vh',
454
+ overflow: 'hidden',
455
+ display: 'flex',
456
+ flexDirection: 'column',
457
+ borderRadius: 'var(--dsw-radius-lg, 10px)',
458
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
459
+ background: 'Canvas',
460
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
461
+ color: 'var(--dsw-alias-label-primary, #1b1d21)',
462
+ }
463
+
464
+ const SWITCHER_INPUT = {
465
+ border: 'none',
466
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
467
+ background: 'transparent',
468
+ color: 'inherit',
469
+ fontSize: '14px',
470
+ padding: '12px 14px',
471
+ outline: 'none',
472
+ }
473
+
474
+ const SWITCHER_LIST = {
475
+ overflowY: 'auto',
476
+ padding: '4px',
477
+ display: 'flex',
478
+ flexDirection: 'column',
479
+ gap: '1px',
480
+ }
481
+
482
+ const SWITCHER_ROW = {
483
+ display: 'flex',
484
+ alignItems: 'center',
485
+ gap: '8px',
486
+ padding: '7px 10px',
487
+ border: 'none',
488
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
489
+ background: 'transparent',
490
+ color: 'inherit',
491
+ fontSize: '13px',
492
+ textAlign: 'left',
493
+ cursor: 'pointer',
494
+ }
495
+
496
+ const SWITCHER_HINT = {
497
+ flex: 'none',
498
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
499
+ fontSize: '11px',
500
+ fontVariantNumeric: 'tabular-nums',
501
+ }
502
+
503
+ /**
504
+ * A workspace whose label carries a path ("Work/Client A") files itself under
505
+ * "Work". The grouping is purely visual and derived from the title, so nothing is
506
+ * written to the Host and renaming a workspace is enough to move it between
507
+ * folders — the same idea as the reference plugin, without its "/" toggle.
508
+ */
509
+ export function folderKey(workspace) {
510
+ const label = workspaceLabel(workspace)
511
+ const cut = label.lastIndexOf('/')
512
+ if (cut <= 0 || cut === label.length - 1) return undefined
513
+ return label.slice(0, cut).trim() || undefined
514
+ }
515
+
516
+ export function readOrder(storage) {
517
+ try {
518
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
519
+ if (!store) return []
520
+ const raw = store.getItem(ORDER_KEY)
521
+ if (!raw) return []
522
+ const parsed = JSON.parse(raw)
523
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
524
+ } catch {
525
+ return []
526
+ }
527
+ }
528
+
529
+ export function writeOrder(ids, storage) {
530
+ try {
531
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
532
+ if (!store) return
533
+ store.setItem(ORDER_KEY, JSON.stringify(Array.isArray(ids) ? ids : []))
534
+ } catch {
535
+ /* storage is a convenience here, never a requirement */
536
+ }
537
+ }
538
+
539
+ /**
540
+ * The insertBefore calls that turn one order into another. `insertBefore(moving, before)`
541
+ * is the only reorder verb the Host offers, so the plan walks the wanted order and moves
542
+ * each workspace in front of whatever currently sits at that slot.
543
+ */
544
+ export function planReorder(currentIds, wantedIds) {
545
+ const current = Array.isArray(currentIds) ? currentIds.slice() : []
546
+ const ops = []
547
+ // Only ids the Host still has can move; a saved order that names a deleted
548
+ // workspace must not shuffle the rest into the wrong slots.
549
+ const wanted = (Array.isArray(wantedIds) ? wantedIds : []).filter((id) => current.indexOf(id) !== -1)
550
+ for (let i = 0; i < wanted.length; i += 1) {
551
+ const id = wanted[i]
552
+ const from = current.indexOf(id)
553
+ if (from === -1 || from === i) continue
554
+ const beforeId = current[i]
555
+ ops.push({ movingId: id, beforeId })
556
+ current.splice(from, 1)
557
+ current.splice(i, 0, id)
558
+ }
559
+ return ops
560
+ }
561
+
562
+ const WS_FOLDER_ROW = {
563
+ display: 'flex',
564
+ alignItems: 'center',
565
+ gap: '5px',
566
+ padding: '3px 6px 2px',
567
+ fontSize: 'calc(' + 'var(--dsh-content-font-size-secondary, 13px)' + ' - 2px)',
568
+ letterSpacing: '0.02em',
569
+ textTransform: 'uppercase',
570
+ color: 'var(--dsw-alias-label-tertiary, #61666b)',
571
+ cursor: 'pointer',
572
+ userSelect: 'none',
573
+ }
386
574
 
387
575
  export const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
388
576
 
@@ -513,7 +701,7 @@ export function BetterWorkspace(props) {
513
701
  const services = props.services || {}
514
702
  const { ctx, slots } = services
515
703
  const standard = { wide: props.wide === true, expandSidebar: props.expandSidebar }
516
- const [expanded, setExpanded] = React.useState({})
704
+ const [expanded, setExpanded] = React.useState(() => readFolded().workspaces)
517
705
  const [menu, setMenu] = React.useState(null)
518
706
  const [armed, setArmed] = React.useState(null)
519
707
  const [adding, setAdding] = React.useState(false)
@@ -524,6 +712,25 @@ export function BetterWorkspace(props) {
524
712
  const [drag, setDrag] = React.useState(null)
525
713
  const [dropping, setDropping] = React.useState(null)
526
714
  const [look, setLook] = React.useState(() => readAppearance())
715
+ const folded = React.useMemo(() => readFolded(), [])
716
+ const [folders, setFolders] = React.useState(folded.folders)
717
+ const [switcher, setSwitcher] = React.useState(null)
718
+ React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
719
+ const [renaming, setRenaming] = React.useState(null)
720
+ const commitRename = async () => {
721
+ const draft = renaming
722
+ setRenaming(null)
723
+ if (!draft || !services.renameWorkspace) return
724
+ const title = String(draft.value || '').trim()
725
+ if (title === '' || title === draft.label) return
726
+ try {
727
+ await services.renameWorkspace(draft.workspaceId, title)
728
+ setNotice('Renamed to ' + title + '.')
729
+ } catch {
730
+ setNotice('Could not rename that workspace.')
731
+ }
732
+ }
733
+ const [savedOrder, setSavedOrder] = React.useState(() => readOrder())
527
734
  const paintLook = (workspaceId, patch) => {
528
735
  setLook((prev) => {
529
736
  const next = withAppearance(prev, workspaceId, patch)
@@ -545,7 +752,12 @@ export function BetterWorkspace(props) {
545
752
  )
546
753
  const allRows = workspaceRows(workspaceList, sessionList)
547
754
  const rows = filterTree(allRows, query, pinned)
548
- const walk = flattenRows(rows, expanded, query)
755
+ const folderOf = (workspace) => folderKey(workspace)
756
+ const visibleRows = rows.filter((group) => {
757
+ const key = folderOf(group.workspace)
758
+ return key === undefined || folders[key] !== true
759
+ })
760
+ const walk = flattenRows(visibleRows, expanded, query)
549
761
  const runtime = React.useMemo(() => menuRuntime(ctx, slots, standard), [ctx, slots, standard.wide])
550
762
  const RowBoundary = React.useMemo(() => rowBoundary(), [])
551
763
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
@@ -570,6 +782,36 @@ export function BetterWorkspace(props) {
570
782
  return () => document.removeEventListener('keydown', onShortcut)
571
783
  }, [])
572
784
 
785
+ React.useEffect(() => {
786
+ const onOpen = (event) => {
787
+ const key = (event.key || '').toLowerCase()
788
+ if (!(event.ctrlKey || event.metaKey) || key !== 'k') return
789
+ event.preventDefault()
790
+ setSwitcher((prev) => (prev ? null : { query: '', index: 0 }))
791
+ }
792
+ const onEscape = (event) => {
793
+ if (event.key === 'Escape') setSwitcher(null)
794
+ }
795
+ document.addEventListener('keydown', onOpen)
796
+ document.addEventListener('keydown', onEscape)
797
+ return () => {
798
+ document.removeEventListener('keydown', onOpen)
799
+ document.removeEventListener('keydown', onEscape)
800
+ }
801
+ }, [])
802
+
803
+ const switchItems = React.useMemo(() => {
804
+ const items = []
805
+ allRows.forEach((group) => {
806
+ const workspace = workspaceLabel(group.workspace)
807
+ group.sessions.forEach((row) => {
808
+ items.push({ id: row.id, title: row.displayTitle || row.id, workspace })
809
+ })
810
+ })
811
+ return items
812
+ }, [allRows])
813
+ const switchHits = switcher ? rankMatches(switchItems, switcher.query) : []
814
+
573
815
  const openMenu = (event, row, workspace) => {
574
816
  event.preventDefault()
575
817
  event.stopPropagation()
@@ -672,6 +914,47 @@ export function BetterWorkspace(props) {
672
914
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
673
915
  },
674
916
  }, 'Copy path'),
917
+ React.createElement('button', {
918
+ type: 'button',
919
+ 'data-polish-ws-rename': 'open',
920
+ style: WS_MENU_ITEM,
921
+ onClick: () => {
922
+ const workspace = menu.workspace
923
+ closeMenu()
924
+ setNotice('')
925
+ setRenaming({ workspaceId: workspace.workspaceId, label: workspaceLabel(workspace), value: workspaceLabel(workspace) })
926
+ },
927
+ }, 'Rename workspace…'),
928
+ React.createElement('button', {
929
+ type: 'button',
930
+ 'data-polish-order': 'remember',
931
+ style: WS_MENU_ITEM,
932
+ onClick: () => {
933
+ closeMenu()
934
+ const ids = workspaceList.map((workspace) => workspace.workspaceId)
935
+ writeOrder(ids)
936
+ setSavedOrder(ids)
937
+ setNotice('Order saved.')
938
+ },
939
+ }, 'Remember this order'),
940
+ savedOrder.length === 0
941
+ ? null
942
+ : React.createElement('button', {
943
+ type: 'button',
944
+ 'data-polish-order': 'restore',
945
+ style: WS_MENU_ITEM,
946
+ onClick: async () => {
947
+ closeMenu()
948
+ const ops = planReorder(workspaceList.map((workspace) => workspace.workspaceId), savedOrder)
949
+ if (ops.length === 0) { setNotice('Already in the saved order.'); return }
950
+ try {
951
+ for (const op of ops) await services.reorderWorkspace(op.movingId, op.beforeId)
952
+ setNotice('Order restored.')
953
+ } catch {
954
+ setNotice('Could not restore that order.')
955
+ }
956
+ },
957
+ }, 'Restore saved order'),
675
958
  React.createElement('button', {
676
959
  type: 'button',
677
960
  'data-polish-ws-appearance': 'open',
@@ -747,6 +1030,56 @@ export function BetterWorkspace(props) {
747
1030
  null)
748
1031
  : null
749
1032
 
1033
+ const switcherCard = switcher
1034
+ ? ReactDOM.createPortal(React.createElement('div', {
1035
+ 'data-polish-switcher': 'open',
1036
+ style: SWITCHER_BACKDROP,
1037
+ onPointerDown: (event) => { if (event.target === event.currentTarget) setSwitcher(null) },
1038
+ },
1039
+ React.createElement('div', { style: SWITCHER_CARD },
1040
+ React.createElement('input', {
1041
+ 'data-polish-switcher-input': 'true',
1042
+ autoFocus: true,
1043
+ value: switcher.query,
1044
+ placeholder: 'Jump to a session…',
1045
+ 'aria-label': 'Jump to a session',
1046
+ style: SWITCHER_INPUT,
1047
+ onChange: (event) => setSwitcher(Object.assign({}, switcher, { query: event.target.value, index: 0 })),
1048
+ onKeyDown: (event) => {
1049
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1050
+ if (switchHits.length === 0) return
1051
+ event.preventDefault()
1052
+ const step = event.key === 'ArrowDown' ? 1 : -1
1053
+ const next = (switcher.index + step + switchHits.length) % switchHits.length
1054
+ setSwitcher(Object.assign({}, switcher, { index: next }))
1055
+ return
1056
+ }
1057
+ if (event.key === 'Enter') {
1058
+ const hit = switchHits[switcher.index]
1059
+ if (!hit) return
1060
+ event.preventDefault()
1061
+ setSwitcher(null)
1062
+ services.openSession(hit.id)
1063
+ }
1064
+ },
1065
+ }),
1066
+ React.createElement('div', { 'data-polish-switcher-list': 'true', style: SWITCHER_LIST },
1067
+ switchHits.length === 0
1068
+ ? React.createElement('div', { style: Object.assign({}, SWITCHER_ROW, { cursor: 'default' }) }, 'No session matches that.')
1069
+ : switchHits.map((hit, at) => React.createElement('button', {
1070
+ key: hit.id,
1071
+ type: 'button',
1072
+ 'data-polish-switcher-row': hit.id,
1073
+ style: Object.assign({}, SWITCHER_ROW, {
1074
+ background: at === switcher.index ? 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' : 'transparent',
1075
+ }),
1076
+ onPointerDown: (event) => event.preventDefault(),
1077
+ onClick: () => { setSwitcher(null); services.openSession(hit.id) },
1078
+ },
1079
+ React.createElement('span', { style: WS_TITLE }, hit.title),
1080
+ React.createElement('span', { style: SWITCHER_HINT }, hit.workspace)))))), document.body)
1081
+ : null
1082
+
750
1083
  const children = []
751
1084
 
752
1085
  children.push(React.createElement('div', { key: 'header', 'data-polish-ws': 'header', style: WS_HEADER },
@@ -808,6 +1141,26 @@ export function BetterWorkspace(props) {
808
1141
  }))
809
1142
  }
810
1143
 
1144
+ if (renaming) {
1145
+ children.push(React.createElement('input', {
1146
+ key: 'rename',
1147
+ 'data-polish-ws-rename-input': 'true',
1148
+ autoFocus: true,
1149
+ value: renaming.value,
1150
+ placeholder: 'Workspace name (use Work/Name to file it)',
1151
+ 'aria-label': 'Workspace name',
1152
+ style: WS_FILTER,
1153
+ onChange: (event) => setRenaming(Object.assign({}, renaming, { value: event.target.value })),
1154
+ onKeyDown: (event) => {
1155
+ if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); return }
1156
+ if (event.key !== 'Enter') return
1157
+ event.preventDefault()
1158
+ void commitRename()
1159
+ },
1160
+ onBlur: () => { void commitRename() },
1161
+ }))
1162
+ }
1163
+
811
1164
  if (rows.length === 0) {
812
1165
  children.push(React.createElement('div', {
813
1166
  key: 'empty',
@@ -816,14 +1169,82 @@ export function BetterWorkspace(props) {
816
1169
  }
817
1170
 
818
1171
  let index = -1
1172
+ const announced = {}
1173
+
1174
+ const sessionRow = (workspace, row, groupIndex) => {
1175
+ const rowFocused = cursor === groupIndex
1176
+ const rowHovered = hover === 'session:' + row.id
1177
+ const lifted = dropping === 'session:' + row.id
1178
+ return React.createElement('div', {
1179
+ key: 'session:' + row.id,
1180
+ 'data-polish-ws-session': row.id,
1181
+ draggable: true,
1182
+ style: Object.assign({}, WS_ROW, {
1183
+ paddingLeft: '24px',
1184
+ opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
1185
+ background: lifted
1186
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1187
+ : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
1188
+ boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
1189
+ }),
1190
+ onClick: () => services.openSession(row.id),
1191
+ onContextMenu: (event) => openMenu(event, row, workspace),
1192
+ onMouseEnter: () => setHover('session:' + row.id),
1193
+ onMouseLeave: () => setHover(null),
1194
+ onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
1195
+ onDragOver: (event) => {
1196
+ if (!drag || drag.kind !== 'session' || drag.id === row.id) return
1197
+ event.preventDefault()
1198
+ setDropping('session:' + row.id)
1199
+ },
1200
+ onDragLeave: () => setDropping(null),
1201
+ onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
1202
+ },
1203
+ row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1204
+ pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1205
+ React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
1206
+ rowHovered || rowFocused
1207
+ ? React.createElement('button', {
1208
+ type: 'button',
1209
+ 'data-polish-ws-menu': 'session',
1210
+ 'aria-label': 'Session menu',
1211
+ style: WS_ROW_ACTION,
1212
+ onClick: (event) => openMenu(event, row, workspace),
1213
+ }, React.createElement(DotsGlyph))
1214
+ : null)
1215
+ }
1216
+
819
1217
  rows.forEach((group) => {
820
1218
  const workspace = group.workspace
821
1219
  const sessions = group.sessions
1220
+ const folder = folderOf(workspace)
1221
+ const folded = folder !== undefined && folders[folder] === true
1222
+
1223
+ if (folder !== undefined && !announced[folder]) {
1224
+ announced[folder] = true
1225
+ const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1226
+ children.push(React.createElement('div', {
1227
+ key: 'folder:' + folder,
1228
+ 'data-polish-ws-folder': folder,
1229
+ role: 'group',
1230
+ 'aria-label': folder,
1231
+ title: folded ? 'Expand ' + folder : 'Collapse ' + folder,
1232
+ style: WS_FOLDER_ROW,
1233
+ onClick: () => setFolders((prev) => Object.assign({}, prev, { [folder]: prev[folder] !== true })),
1234
+ },
1235
+ React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1236
+ React.createElement('span', { style: WS_TITLE }, folder),
1237
+ React.createElement('span', { style: WS_COUNT }, String(inside.reduce((sum, entry) => sum + entry.sessions.length, 0)))))
1238
+ }
1239
+
1240
+ if (folded) return
1241
+
822
1242
  index += 1
823
1243
  const open = expanded[workspace.workspaceId] !== false
824
1244
  const focused = cursor === index
825
1245
  const hovered = hover === 'workspace:' + workspace.workspaceId
826
1246
  const dropHere = dropping === 'workspace:' + workspace.workspaceId
1247
+ const looks = appearanceOf(look, workspace.workspaceId)
827
1248
 
828
1249
  children.push(React.createElement('div', {
829
1250
  key: 'workspace:' + workspace.workspaceId,
@@ -855,15 +1276,11 @@ export function BetterWorkspace(props) {
855
1276
  },
856
1277
  React.createElement(CaretGlyph, { open }),
857
1278
  React.createElement(FolderGlyph),
858
- look[workspace.workspaceId] && appearanceOf(look, workspace.workspaceId).tone
859
- ? React.createElement('span', {
860
- style: Object.assign({}, WS_TONE, { background: TONE_COLOR[appearanceOf(look, workspace.workspaceId).tone] }),
861
- })
1279
+ looks.tone
1280
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
862
1281
  : null,
863
1282
  React.createElement('span', {
864
- style: Object.assign({}, WS_TITLE, {
865
- fontWeight: appearanceOf(look, workspace.workspaceId).strong ? 600 : undefined,
866
- }),
1283
+ style: Object.assign({}, WS_TITLE, { fontWeight: looks.strong ? 600 : undefined }),
867
1284
  }, workspaceLabel(workspace)),
868
1285
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
869
1286
  hovered || focused
@@ -879,46 +1296,7 @@ export function BetterWorkspace(props) {
879
1296
  if (open) {
880
1297
  sessions.forEach((row) => {
881
1298
  index += 1
882
- const rowFocused = cursor === index
883
- const rowHovered = hover === 'session:' + row.id
884
- const lifted = dropping === 'session:' + row.id
885
- children.push(React.createElement('div', {
886
- key: 'session:' + row.id,
887
- 'data-polish-ws-session': row.id,
888
- draggable: true,
889
- style: Object.assign({}, WS_ROW, {
890
- paddingLeft: '24px',
891
- opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
892
- background: lifted
893
- ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
894
- : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
895
- boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
896
- }),
897
- onClick: () => services.openSession(row.id),
898
- onContextMenu: (event) => openMenu(event, row, workspace),
899
- onMouseEnter: () => setHover('session:' + row.id),
900
- onMouseLeave: () => setHover(null),
901
- onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
902
- onDragOver: (event) => {
903
- if (!drag || drag.kind !== 'session' || drag.id === row.id) return
904
- event.preventDefault()
905
- setDropping('session:' + row.id)
906
- },
907
- onDragLeave: () => setDropping(null),
908
- onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
909
- },
910
- row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
911
- pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
912
- React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
913
- rowHovered || rowFocused
914
- ? React.createElement('button', {
915
- type: 'button',
916
- 'data-polish-ws-menu': 'session',
917
- 'aria-label': 'Session menu',
918
- style: WS_ROW_ACTION,
919
- onClick: (event) => openMenu(event, row, workspace),
920
- }, React.createElement(DotsGlyph))
921
- : null))
1299
+ children.push(sessionRow(workspace, row, index))
922
1300
  })
923
1301
  }
924
1302
  })
@@ -945,6 +1323,7 @@ export function BetterWorkspace(props) {
945
1323
  shownCount < totalCount
946
1324
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
947
1325
  : null,
1326
+ switcherCard,
948
1327
  sessionMenu,
949
1328
  paintMenu,
950
1329
  workspaceMenu)
@@ -1071,6 +1450,7 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
1071
1450
  reorderWorkspace: (movingId, beforeId) => seated.workspaces.insertBefore(movingId, beforeId),
1072
1451
  reorderSession: (workspaceId, movingId, beforeId) => seated.workspaces.insertSessionBefore(workspaceId, movingId, beforeId),
1073
1452
  removeWorkspace: (id) => seated.workspaces.delete(id),
1453
+ renameWorkspace: (id, title) => seated.workspaces.rename(id, title),
1074
1454
  openFolder: async (path) => {
1075
1455
  const answer = await seated.remote.session.openWorkspacePath({ path })
1076
1456
  if (answer && answer.ok === false) throw new Error('cannot open the folder')
@@ -114,3 +114,41 @@ switch. The slot hands over `{ open, anchorRef, selectedId, onPick, onClose }`:
114
114
  is portalled to the anchor's bottom-left only while `open` is true (an unconditional
115
115
  list floated over the Hero copy), it shows the same tone dots and bold labels as the
116
116
  sidebar, and picking a row calls `onPick(workspaceId)` then `onClose()`.
117
+
118
+ ## Folders from titles
119
+
120
+ A workspace whose label carries a path files itself under the prefix: rename
121
+ `Alpha` to `Work/Alpha` (workspace menu → **Rename workspace…**) and a collapsible
122
+ `WORK` row appears, carrying the session count of everything filed inside it. The
123
+ group is derived on every render from `folderKey(workspace)` — nothing is written to the
124
+ Host, nothing is cached, and renaming the workspace out of the prefix puts it back in
125
+ the flat list. `folderKey` refuses a leading slash, a trailing slash and a bare label, so
126
+ ordinary names never grow a folder.
127
+
128
+ ## Order presets
129
+
130
+ **Remember this order** stores the current workspace ids under
131
+ `aryacode-polish:workspace-order.v1`; **Restore saved order** replays them. The Host
132
+ offers exactly one reorder verb (`insertBefore(movingId, beforeId)`), so restoring is
133
+ `planReorder(current, wanted)` — a pure function that walks the wanted order and emits
134
+ one move per workspace that sits in the wrong slot. Ids the Host no longer has are
135
+ filtered out first, so a preset that names a deleted workspace cannot shuffle the
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.
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.12.1"
56
+ const CLIENT_VERSION = "0.14.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. */
@@ -584,6 +584,194 @@ window.__ModuleLoader__.load({
584
584
  }
585
585
 
586
586
  const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
587
+ const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
588
+ const FOLD_KEY = 'aryacode-polish:folded.v1'
589
+
590
+ /** Which workspaces and folders are folded, kept so a restart looks like yesterday. */
591
+ function readFolded(storage) {
592
+ try {
593
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
594
+ if (!store) return { workspaces: {}, folders: {} }
595
+ const raw = store.getItem(FOLD_KEY)
596
+ if (!raw) return { workspaces: {}, folders: {} }
597
+ const parsed = JSON.parse(raw)
598
+ return {
599
+ workspaces: (parsed && parsed.workspaces) || {},
600
+ folders: (parsed && parsed.folders) || {},
601
+ }
602
+ } catch {
603
+ return { workspaces: {}, folders: {} }
604
+ }
605
+ }
606
+
607
+ function writeFolded(state, storage) {
608
+ try {
609
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
610
+ if (!store) return
611
+ const clean = {
612
+ workspaces: (state && state.workspaces) || {},
613
+ folders: (state && state.folders) || {},
614
+ }
615
+ store.setItem(FOLD_KEY, JSON.stringify(clean))
616
+ } catch {
617
+ /* folding is a convenience; a store that refuses must not break the tree */
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Rank the quick switcher's rows. A title that starts with the query wins over one
623
+ * that merely contains it, and equal kinds keep the order they arrived in — so
624
+ * typing "gre" puts Greeting above anything that mentions greeting later on.
625
+ */
626
+ function rankMatches(items, query, text = (item) => String(item.title || item.id || '')) {
627
+ const list = Array.isArray(items) ? items : []
628
+ const needle = String(query || '').trim().toLowerCase()
629
+ if (!needle) return list.slice(0, 40)
630
+ const scored = []
631
+ list.forEach((item, order) => {
632
+ const hay = text(item).toLowerCase()
633
+ const at = hay.indexOf(needle)
634
+ if (at === -1) return
635
+ scored.push({ item, rank: at === 0 ? 0 : 1, order })
636
+ })
637
+ scored.sort((a, b) => (a.rank - b.rank) || (a.order - b.order))
638
+ return scored.slice(0, 40).map((entry) => entry.item)
639
+ }
640
+
641
+ const SWITCHER_BACKDROP = {
642
+ position: 'fixed',
643
+ inset: 0,
644
+ zIndex: 1300,
645
+ display: 'flex',
646
+ alignItems: 'flex-start',
647
+ justifyContent: 'center',
648
+ paddingTop: '12vh',
649
+ background: 'rgba(16, 18, 22, 0.28)',
650
+ }
651
+
652
+ const SWITCHER_CARD = {
653
+ width: 'min(560px, calc(100vw - 32px))',
654
+ maxHeight: '60vh',
655
+ overflow: 'hidden',
656
+ display: 'flex',
657
+ flexDirection: 'column',
658
+ borderRadius: 'var(--dsw-radius-lg, 10px)',
659
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
660
+ background: 'Canvas',
661
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 24px rgba(0,0,0,0.18))',
662
+ color: 'var(--dsw-alias-label-primary, #1b1d21)',
663
+ }
664
+
665
+ const SWITCHER_INPUT = {
666
+ border: 'none',
667
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
668
+ background: 'transparent',
669
+ color: 'inherit',
670
+ fontSize: '14px',
671
+ padding: '12px 14px',
672
+ outline: 'none',
673
+ }
674
+
675
+ const SWITCHER_LIST = {
676
+ overflowY: 'auto',
677
+ padding: '4px',
678
+ display: 'flex',
679
+ flexDirection: 'column',
680
+ gap: '1px',
681
+ }
682
+
683
+ const SWITCHER_ROW = {
684
+ display: 'flex',
685
+ alignItems: 'center',
686
+ gap: '8px',
687
+ padding: '7px 10px',
688
+ border: 'none',
689
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
690
+ background: 'transparent',
691
+ color: 'inherit',
692
+ fontSize: '13px',
693
+ textAlign: 'left',
694
+ cursor: 'pointer',
695
+ }
696
+
697
+ const SWITCHER_HINT = {
698
+ flex: 'none',
699
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
700
+ fontSize: '11px',
701
+ fontVariantNumeric: 'tabular-nums',
702
+ }
703
+
704
+ /**
705
+ * A workspace whose label carries a path ("Work/Client A") files itself under
706
+ * "Work". The grouping is purely visual and derived from the title, so nothing is
707
+ * written to the Host and renaming a workspace is enough to move it between
708
+ * folders — the same idea as the reference plugin, without its "/" toggle.
709
+ */
710
+ function folderKey(workspace) {
711
+ const label = workspaceLabel(workspace)
712
+ const cut = label.lastIndexOf('/')
713
+ if (cut <= 0 || cut === label.length - 1) return undefined
714
+ return label.slice(0, cut).trim() || undefined
715
+ }
716
+
717
+ function readOrder(storage) {
718
+ try {
719
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
720
+ if (!store) return []
721
+ const raw = store.getItem(ORDER_KEY)
722
+ if (!raw) return []
723
+ const parsed = JSON.parse(raw)
724
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
725
+ } catch {
726
+ return []
727
+ }
728
+ }
729
+
730
+ function writeOrder(ids, storage) {
731
+ try {
732
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
733
+ if (!store) return
734
+ store.setItem(ORDER_KEY, JSON.stringify(Array.isArray(ids) ? ids : []))
735
+ } catch {
736
+ /* storage is a convenience here, never a requirement */
737
+ }
738
+ }
739
+
740
+ /**
741
+ * The insertBefore calls that turn one order into another. `insertBefore(moving, before)`
742
+ * is the only reorder verb the Host offers, so the plan walks the wanted order and moves
743
+ * each workspace in front of whatever currently sits at that slot.
744
+ */
745
+ function planReorder(currentIds, wantedIds) {
746
+ const current = Array.isArray(currentIds) ? currentIds.slice() : []
747
+ const ops = []
748
+ // Only ids the Host still has can move; a saved order that names a deleted
749
+ // workspace must not shuffle the rest into the wrong slots.
750
+ const wanted = (Array.isArray(wantedIds) ? wantedIds : []).filter((id) => current.indexOf(id) !== -1)
751
+ for (let i = 0; i < wanted.length; i += 1) {
752
+ const id = wanted[i]
753
+ const from = current.indexOf(id)
754
+ if (from === -1 || from === i) continue
755
+ const beforeId = current[i]
756
+ ops.push({ movingId: id, beforeId })
757
+ current.splice(from, 1)
758
+ current.splice(i, 0, id)
759
+ }
760
+ return ops
761
+ }
762
+
763
+ const WS_FOLDER_ROW = {
764
+ display: 'flex',
765
+ alignItems: 'center',
766
+ gap: '5px',
767
+ padding: '3px 6px 2px',
768
+ fontSize: 'calc(' + 'var(--dsh-content-font-size-secondary, 13px)' + ' - 2px)',
769
+ letterSpacing: '0.02em',
770
+ textTransform: 'uppercase',
771
+ color: 'var(--dsw-alias-label-tertiary, #61666b)',
772
+ cursor: 'pointer',
773
+ userSelect: 'none',
774
+ }
587
775
 
588
776
  const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
589
777
 
@@ -714,7 +902,7 @@ window.__ModuleLoader__.load({
714
902
  const services = props.services || {}
715
903
  const { ctx, slots } = services
716
904
  const standard = { wide: props.wide === true, expandSidebar: props.expandSidebar }
717
- const [expanded, setExpanded] = React.useState({})
905
+ const [expanded, setExpanded] = React.useState(() => readFolded().workspaces)
718
906
  const [menu, setMenu] = React.useState(null)
719
907
  const [armed, setArmed] = React.useState(null)
720
908
  const [adding, setAdding] = React.useState(false)
@@ -725,6 +913,25 @@ window.__ModuleLoader__.load({
725
913
  const [drag, setDrag] = React.useState(null)
726
914
  const [dropping, setDropping] = React.useState(null)
727
915
  const [look, setLook] = React.useState(() => readAppearance())
916
+ const folded = React.useMemo(() => readFolded(), [])
917
+ const [folders, setFolders] = React.useState(folded.folders)
918
+ const [switcher, setSwitcher] = React.useState(null)
919
+ React.useEffect(() => { writeFolded({ workspaces: expanded, folders }) }, [expanded, folders])
920
+ const [renaming, setRenaming] = React.useState(null)
921
+ const commitRename = async () => {
922
+ const draft = renaming
923
+ setRenaming(null)
924
+ if (!draft || !services.renameWorkspace) return
925
+ const title = String(draft.value || '').trim()
926
+ if (title === '' || title === draft.label) return
927
+ try {
928
+ await services.renameWorkspace(draft.workspaceId, title)
929
+ setNotice('Renamed to ' + title + '.')
930
+ } catch {
931
+ setNotice('Could not rename that workspace.')
932
+ }
933
+ }
934
+ const [savedOrder, setSavedOrder] = React.useState(() => readOrder())
728
935
  const paintLook = (workspaceId, patch) => {
729
936
  setLook((prev) => {
730
937
  const next = withAppearance(prev, workspaceId, patch)
@@ -746,7 +953,12 @@ window.__ModuleLoader__.load({
746
953
  )
747
954
  const allRows = workspaceRows(workspaceList, sessionList)
748
955
  const rows = filterTree(allRows, query, pinned)
749
- const walk = flattenRows(rows, expanded, query)
956
+ const folderOf = (workspace) => folderKey(workspace)
957
+ const visibleRows = rows.filter((group) => {
958
+ const key = folderOf(group.workspace)
959
+ return key === undefined || folders[key] !== true
960
+ })
961
+ const walk = flattenRows(visibleRows, expanded, query)
750
962
  const runtime = React.useMemo(() => menuRuntime(ctx, slots, standard), [ctx, slots, standard.wide])
751
963
  const RowBoundary = React.useMemo(() => rowBoundary(), [])
752
964
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
@@ -771,6 +983,36 @@ window.__ModuleLoader__.load({
771
983
  return () => document.removeEventListener('keydown', onShortcut)
772
984
  }, [])
773
985
 
986
+ React.useEffect(() => {
987
+ const onOpen = (event) => {
988
+ const key = (event.key || '').toLowerCase()
989
+ if (!(event.ctrlKey || event.metaKey) || key !== 'k') return
990
+ event.preventDefault()
991
+ setSwitcher((prev) => (prev ? null : { query: '', index: 0 }))
992
+ }
993
+ const onEscape = (event) => {
994
+ if (event.key === 'Escape') setSwitcher(null)
995
+ }
996
+ document.addEventListener('keydown', onOpen)
997
+ document.addEventListener('keydown', onEscape)
998
+ return () => {
999
+ document.removeEventListener('keydown', onOpen)
1000
+ document.removeEventListener('keydown', onEscape)
1001
+ }
1002
+ }, [])
1003
+
1004
+ const switchItems = React.useMemo(() => {
1005
+ const items = []
1006
+ allRows.forEach((group) => {
1007
+ const workspace = workspaceLabel(group.workspace)
1008
+ group.sessions.forEach((row) => {
1009
+ items.push({ id: row.id, title: row.displayTitle || row.id, workspace })
1010
+ })
1011
+ })
1012
+ return items
1013
+ }, [allRows])
1014
+ const switchHits = switcher ? rankMatches(switchItems, switcher.query) : []
1015
+
774
1016
  const openMenu = (event, row, workspace) => {
775
1017
  event.preventDefault()
776
1018
  event.stopPropagation()
@@ -873,6 +1115,47 @@ window.__ModuleLoader__.load({
873
1115
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
874
1116
  },
875
1117
  }, 'Copy path'),
1118
+ React.createElement('button', {
1119
+ type: 'button',
1120
+ 'data-polish-ws-rename': 'open',
1121
+ style: WS_MENU_ITEM,
1122
+ onClick: () => {
1123
+ const workspace = menu.workspace
1124
+ closeMenu()
1125
+ setNotice('')
1126
+ setRenaming({ workspaceId: workspace.workspaceId, label: workspaceLabel(workspace), value: workspaceLabel(workspace) })
1127
+ },
1128
+ }, 'Rename workspace…'),
1129
+ React.createElement('button', {
1130
+ type: 'button',
1131
+ 'data-polish-order': 'remember',
1132
+ style: WS_MENU_ITEM,
1133
+ onClick: () => {
1134
+ closeMenu()
1135
+ const ids = workspaceList.map((workspace) => workspace.workspaceId)
1136
+ writeOrder(ids)
1137
+ setSavedOrder(ids)
1138
+ setNotice('Order saved.')
1139
+ },
1140
+ }, 'Remember this order'),
1141
+ savedOrder.length === 0
1142
+ ? null
1143
+ : React.createElement('button', {
1144
+ type: 'button',
1145
+ 'data-polish-order': 'restore',
1146
+ style: WS_MENU_ITEM,
1147
+ onClick: async () => {
1148
+ closeMenu()
1149
+ const ops = planReorder(workspaceList.map((workspace) => workspace.workspaceId), savedOrder)
1150
+ if (ops.length === 0) { setNotice('Already in the saved order.'); return }
1151
+ try {
1152
+ for (const op of ops) await services.reorderWorkspace(op.movingId, op.beforeId)
1153
+ setNotice('Order restored.')
1154
+ } catch {
1155
+ setNotice('Could not restore that order.')
1156
+ }
1157
+ },
1158
+ }, 'Restore saved order'),
876
1159
  React.createElement('button', {
877
1160
  type: 'button',
878
1161
  'data-polish-ws-appearance': 'open',
@@ -948,6 +1231,56 @@ window.__ModuleLoader__.load({
948
1231
  null)
949
1232
  : null
950
1233
 
1234
+ const switcherCard = switcher
1235
+ ? ReactDOM.createPortal(React.createElement('div', {
1236
+ 'data-polish-switcher': 'open',
1237
+ style: SWITCHER_BACKDROP,
1238
+ onPointerDown: (event) => { if (event.target === event.currentTarget) setSwitcher(null) },
1239
+ },
1240
+ React.createElement('div', { style: SWITCHER_CARD },
1241
+ React.createElement('input', {
1242
+ 'data-polish-switcher-input': 'true',
1243
+ autoFocus: true,
1244
+ value: switcher.query,
1245
+ placeholder: 'Jump to a session…',
1246
+ 'aria-label': 'Jump to a session',
1247
+ style: SWITCHER_INPUT,
1248
+ onChange: (event) => setSwitcher(Object.assign({}, switcher, { query: event.target.value, index: 0 })),
1249
+ onKeyDown: (event) => {
1250
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1251
+ if (switchHits.length === 0) return
1252
+ event.preventDefault()
1253
+ const step = event.key === 'ArrowDown' ? 1 : -1
1254
+ const next = (switcher.index + step + switchHits.length) % switchHits.length
1255
+ setSwitcher(Object.assign({}, switcher, { index: next }))
1256
+ return
1257
+ }
1258
+ if (event.key === 'Enter') {
1259
+ const hit = switchHits[switcher.index]
1260
+ if (!hit) return
1261
+ event.preventDefault()
1262
+ setSwitcher(null)
1263
+ services.openSession(hit.id)
1264
+ }
1265
+ },
1266
+ }),
1267
+ React.createElement('div', { 'data-polish-switcher-list': 'true', style: SWITCHER_LIST },
1268
+ switchHits.length === 0
1269
+ ? React.createElement('div', { style: Object.assign({}, SWITCHER_ROW, { cursor: 'default' }) }, 'No session matches that.')
1270
+ : switchHits.map((hit, at) => React.createElement('button', {
1271
+ key: hit.id,
1272
+ type: 'button',
1273
+ 'data-polish-switcher-row': hit.id,
1274
+ style: Object.assign({}, SWITCHER_ROW, {
1275
+ background: at === switcher.index ? 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' : 'transparent',
1276
+ }),
1277
+ onPointerDown: (event) => event.preventDefault(),
1278
+ onClick: () => { setSwitcher(null); services.openSession(hit.id) },
1279
+ },
1280
+ React.createElement('span', { style: WS_TITLE }, hit.title),
1281
+ React.createElement('span', { style: SWITCHER_HINT }, hit.workspace)))))), document.body)
1282
+ : null
1283
+
951
1284
  const children = []
952
1285
 
953
1286
  children.push(React.createElement('div', { key: 'header', 'data-polish-ws': 'header', style: WS_HEADER },
@@ -1009,6 +1342,26 @@ window.__ModuleLoader__.load({
1009
1342
  }))
1010
1343
  }
1011
1344
 
1345
+ if (renaming) {
1346
+ children.push(React.createElement('input', {
1347
+ key: 'rename',
1348
+ 'data-polish-ws-rename-input': 'true',
1349
+ autoFocus: true,
1350
+ value: renaming.value,
1351
+ placeholder: 'Workspace name (use Work/Name to file it)',
1352
+ 'aria-label': 'Workspace name',
1353
+ style: WS_FILTER,
1354
+ onChange: (event) => setRenaming(Object.assign({}, renaming, { value: event.target.value })),
1355
+ onKeyDown: (event) => {
1356
+ if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); return }
1357
+ if (event.key !== 'Enter') return
1358
+ event.preventDefault()
1359
+ void commitRename()
1360
+ },
1361
+ onBlur: () => { void commitRename() },
1362
+ }))
1363
+ }
1364
+
1012
1365
  if (rows.length === 0) {
1013
1366
  children.push(React.createElement('div', {
1014
1367
  key: 'empty',
@@ -1017,14 +1370,82 @@ window.__ModuleLoader__.load({
1017
1370
  }
1018
1371
 
1019
1372
  let index = -1
1373
+ const announced = {}
1374
+
1375
+ const sessionRow = (workspace, row, groupIndex) => {
1376
+ const rowFocused = cursor === groupIndex
1377
+ const rowHovered = hover === 'session:' + row.id
1378
+ const lifted = dropping === 'session:' + row.id
1379
+ return React.createElement('div', {
1380
+ key: 'session:' + row.id,
1381
+ 'data-polish-ws-session': row.id,
1382
+ draggable: true,
1383
+ style: Object.assign({}, WS_ROW, {
1384
+ paddingLeft: '24px',
1385
+ opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
1386
+ background: lifted
1387
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1388
+ : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
1389
+ boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
1390
+ }),
1391
+ onClick: () => services.openSession(row.id),
1392
+ onContextMenu: (event) => openMenu(event, row, workspace),
1393
+ onMouseEnter: () => setHover('session:' + row.id),
1394
+ onMouseLeave: () => setHover(null),
1395
+ onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
1396
+ onDragOver: (event) => {
1397
+ if (!drag || drag.kind !== 'session' || drag.id === row.id) return
1398
+ event.preventDefault()
1399
+ setDropping('session:' + row.id)
1400
+ },
1401
+ onDragLeave: () => setDropping(null),
1402
+ onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
1403
+ },
1404
+ row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1405
+ pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1406
+ React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
1407
+ rowHovered || rowFocused
1408
+ ? React.createElement('button', {
1409
+ type: 'button',
1410
+ 'data-polish-ws-menu': 'session',
1411
+ 'aria-label': 'Session menu',
1412
+ style: WS_ROW_ACTION,
1413
+ onClick: (event) => openMenu(event, row, workspace),
1414
+ }, React.createElement(DotsGlyph))
1415
+ : null)
1416
+ }
1417
+
1020
1418
  rows.forEach((group) => {
1021
1419
  const workspace = group.workspace
1022
1420
  const sessions = group.sessions
1421
+ const folder = folderOf(workspace)
1422
+ const folded = folder !== undefined && folders[folder] === true
1423
+
1424
+ if (folder !== undefined && !announced[folder]) {
1425
+ announced[folder] = true
1426
+ const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1427
+ children.push(React.createElement('div', {
1428
+ key: 'folder:' + folder,
1429
+ 'data-polish-ws-folder': folder,
1430
+ role: 'group',
1431
+ 'aria-label': folder,
1432
+ title: folded ? 'Expand ' + folder : 'Collapse ' + folder,
1433
+ style: WS_FOLDER_ROW,
1434
+ onClick: () => setFolders((prev) => Object.assign({}, prev, { [folder]: prev[folder] !== true })),
1435
+ },
1436
+ React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1437
+ React.createElement('span', { style: WS_TITLE }, folder),
1438
+ React.createElement('span', { style: WS_COUNT }, String(inside.reduce((sum, entry) => sum + entry.sessions.length, 0)))))
1439
+ }
1440
+
1441
+ if (folded) return
1442
+
1023
1443
  index += 1
1024
1444
  const open = expanded[workspace.workspaceId] !== false
1025
1445
  const focused = cursor === index
1026
1446
  const hovered = hover === 'workspace:' + workspace.workspaceId
1027
1447
  const dropHere = dropping === 'workspace:' + workspace.workspaceId
1448
+ const looks = appearanceOf(look, workspace.workspaceId)
1028
1449
 
1029
1450
  children.push(React.createElement('div', {
1030
1451
  key: 'workspace:' + workspace.workspaceId,
@@ -1056,15 +1477,11 @@ window.__ModuleLoader__.load({
1056
1477
  },
1057
1478
  React.createElement(CaretGlyph, { open }),
1058
1479
  React.createElement(FolderGlyph),
1059
- look[workspace.workspaceId] && appearanceOf(look, workspace.workspaceId).tone
1060
- ? React.createElement('span', {
1061
- style: Object.assign({}, WS_TONE, { background: TONE_COLOR[appearanceOf(look, workspace.workspaceId).tone] }),
1062
- })
1480
+ looks.tone
1481
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1063
1482
  : null,
1064
1483
  React.createElement('span', {
1065
- style: Object.assign({}, WS_TITLE, {
1066
- fontWeight: appearanceOf(look, workspace.workspaceId).strong ? 600 : undefined,
1067
- }),
1484
+ style: Object.assign({}, WS_TITLE, { fontWeight: looks.strong ? 600 : undefined }),
1068
1485
  }, workspaceLabel(workspace)),
1069
1486
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
1070
1487
  hovered || focused
@@ -1080,46 +1497,7 @@ window.__ModuleLoader__.load({
1080
1497
  if (open) {
1081
1498
  sessions.forEach((row) => {
1082
1499
  index += 1
1083
- const rowFocused = cursor === index
1084
- const rowHovered = hover === 'session:' + row.id
1085
- const lifted = dropping === 'session:' + row.id
1086
- children.push(React.createElement('div', {
1087
- key: 'session:' + row.id,
1088
- 'data-polish-ws-session': row.id,
1089
- draggable: true,
1090
- style: Object.assign({}, WS_ROW, {
1091
- paddingLeft: '24px',
1092
- opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
1093
- background: lifted
1094
- ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1095
- : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
1096
- boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
1097
- }),
1098
- onClick: () => services.openSession(row.id),
1099
- onContextMenu: (event) => openMenu(event, row, workspace),
1100
- onMouseEnter: () => setHover('session:' + row.id),
1101
- onMouseLeave: () => setHover(null),
1102
- onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
1103
- onDragOver: (event) => {
1104
- if (!drag || drag.kind !== 'session' || drag.id === row.id) return
1105
- event.preventDefault()
1106
- setDropping('session:' + row.id)
1107
- },
1108
- onDragLeave: () => setDropping(null),
1109
- onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
1110
- },
1111
- row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1112
- pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1113
- React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
1114
- rowHovered || rowFocused
1115
- ? React.createElement('button', {
1116
- type: 'button',
1117
- 'data-polish-ws-menu': 'session',
1118
- 'aria-label': 'Session menu',
1119
- style: WS_ROW_ACTION,
1120
- onClick: (event) => openMenu(event, row, workspace),
1121
- }, React.createElement(DotsGlyph))
1122
- : null))
1500
+ children.push(sessionRow(workspace, row, index))
1123
1501
  })
1124
1502
  }
1125
1503
  })
@@ -1146,6 +1524,7 @@ window.__ModuleLoader__.load({
1146
1524
  shownCount < totalCount
1147
1525
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
1148
1526
  : null,
1527
+ switcherCard,
1149
1528
  sessionMenu,
1150
1529
  paintMenu,
1151
1530
  workspaceMenu)
@@ -1272,6 +1651,7 @@ window.__ModuleLoader__.load({
1272
1651
  reorderWorkspace: (movingId, beforeId) => seated.workspaces.insertBefore(movingId, beforeId),
1273
1652
  reorderSession: (workspaceId, movingId, beforeId) => seated.workspaces.insertSessionBefore(workspaceId, movingId, beforeId),
1274
1653
  removeWorkspace: (id) => seated.workspaces.delete(id),
1654
+ renameWorkspace: (id, title) => seated.workspaces.rename(id, title),
1275
1655
  openFolder: async (path) => {
1276
1656
  const answer = await seated.remote.session.openWorkspacePath({ path })
1277
1657
  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.12.1",
3
+ "version": "0.14.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",