@aryacode/dsh-aryacode-polish 0.12.1 → 0.13.1

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.13.1] - 2026-10-28
4
+
5
+ ### Added
6
+
7
+ - **Rename a workspace in place.** The workspace menu opens an input under the search
8
+ box; Enter (or blurring) sends `workspaces.rename`, Escape abandons it. Renaming to
9
+ `Work/Client A` is also how a workspace joins a folder.
10
+
11
+ ### Fixed
12
+
13
+ - `planReorder` ignores ids the Host no longer has, so a saved order that names a
14
+ deleted workspace cannot shuffle the survivors into the wrong slots.
15
+
16
+ ## [0.13.0] - 2026-10-28
17
+
18
+ ### Added
19
+
20
+ - **Folders from titles.** A workspace named `Work/Client A` files itself under a
21
+ collapsible `Work` row, with the folder's session count on the right. The grouping is
22
+ derived from the label and written nowhere, so renaming the workspace moves it.
23
+ - **Order presets.** The workspace menu can "Remember this order" and later "Restore
24
+ saved order", which replays the Host's only reorder verb — `insertBefore` — through a
25
+ pure `planReorder(current, wanted)` plan, stored under
26
+ `aryacode-polish:workspace-order.v1`.
27
+
3
28
  ## [0.12.1] - 2026-10-28
4
29
 
5
30
  ### Fixed
@@ -383,6 +383,79 @@ 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
+
388
+ /**
389
+ * A workspace whose label carries a path ("Work/Client A") files itself under
390
+ * "Work". The grouping is purely visual and derived from the title, so nothing is
391
+ * written to the Host and renaming a workspace is enough to move it between
392
+ * folders — the same idea as the reference plugin, without its "/" toggle.
393
+ */
394
+ export function folderKey(workspace) {
395
+ const label = workspaceLabel(workspace)
396
+ const cut = label.lastIndexOf('/')
397
+ if (cut <= 0 || cut === label.length - 1) return undefined
398
+ return label.slice(0, cut).trim() || undefined
399
+ }
400
+
401
+ export function readOrder(storage) {
402
+ try {
403
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
404
+ if (!store) return []
405
+ const raw = store.getItem(ORDER_KEY)
406
+ if (!raw) return []
407
+ const parsed = JSON.parse(raw)
408
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
409
+ } catch {
410
+ return []
411
+ }
412
+ }
413
+
414
+ export function writeOrder(ids, storage) {
415
+ try {
416
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
417
+ if (!store) return
418
+ store.setItem(ORDER_KEY, JSON.stringify(Array.isArray(ids) ? ids : []))
419
+ } catch {
420
+ /* storage is a convenience here, never a requirement */
421
+ }
422
+ }
423
+
424
+ /**
425
+ * The insertBefore calls that turn one order into another. `insertBefore(moving, before)`
426
+ * is the only reorder verb the Host offers, so the plan walks the wanted order and moves
427
+ * each workspace in front of whatever currently sits at that slot.
428
+ */
429
+ export function planReorder(currentIds, wantedIds) {
430
+ const current = Array.isArray(currentIds) ? currentIds.slice() : []
431
+ const ops = []
432
+ // Only ids the Host still has can move; a saved order that names a deleted
433
+ // workspace must not shuffle the rest into the wrong slots.
434
+ const wanted = (Array.isArray(wantedIds) ? wantedIds : []).filter((id) => current.indexOf(id) !== -1)
435
+ for (let i = 0; i < wanted.length; i += 1) {
436
+ const id = wanted[i]
437
+ const from = current.indexOf(id)
438
+ if (from === -1 || from === i) continue
439
+ const beforeId = current[i]
440
+ ops.push({ movingId: id, beforeId })
441
+ current.splice(from, 1)
442
+ current.splice(i, 0, id)
443
+ }
444
+ return ops
445
+ }
446
+
447
+ const WS_FOLDER_ROW = {
448
+ display: 'flex',
449
+ alignItems: 'center',
450
+ gap: '5px',
451
+ padding: '3px 6px 2px',
452
+ fontSize: 'calc(' + 'var(--dsh-content-font-size-secondary, 13px)' + ' - 2px)',
453
+ letterSpacing: '0.02em',
454
+ textTransform: 'uppercase',
455
+ color: 'var(--dsw-alias-label-tertiary, #61666b)',
456
+ cursor: 'pointer',
457
+ userSelect: 'none',
458
+ }
386
459
 
387
460
  export const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
388
461
 
@@ -524,6 +597,22 @@ export function BetterWorkspace(props) {
524
597
  const [drag, setDrag] = React.useState(null)
525
598
  const [dropping, setDropping] = React.useState(null)
526
599
  const [look, setLook] = React.useState(() => readAppearance())
600
+ const [folders, setFolders] = React.useState({})
601
+ const [renaming, setRenaming] = React.useState(null)
602
+ const commitRename = async () => {
603
+ const draft = renaming
604
+ setRenaming(null)
605
+ if (!draft || !services.renameWorkspace) return
606
+ const title = String(draft.value || '').trim()
607
+ if (title === '' || title === draft.label) return
608
+ try {
609
+ await services.renameWorkspace(draft.workspaceId, title)
610
+ setNotice('Renamed to ' + title + '.')
611
+ } catch {
612
+ setNotice('Could not rename that workspace.')
613
+ }
614
+ }
615
+ const [savedOrder, setSavedOrder] = React.useState(() => readOrder())
527
616
  const paintLook = (workspaceId, patch) => {
528
617
  setLook((prev) => {
529
618
  const next = withAppearance(prev, workspaceId, patch)
@@ -545,7 +634,12 @@ export function BetterWorkspace(props) {
545
634
  )
546
635
  const allRows = workspaceRows(workspaceList, sessionList)
547
636
  const rows = filterTree(allRows, query, pinned)
548
- const walk = flattenRows(rows, expanded, query)
637
+ const folderOf = (workspace) => folderKey(workspace)
638
+ const visibleRows = rows.filter((group) => {
639
+ const key = folderOf(group.workspace)
640
+ return key === undefined || folders[key] !== true
641
+ })
642
+ const walk = flattenRows(visibleRows, expanded, query)
549
643
  const runtime = React.useMemo(() => menuRuntime(ctx, slots, standard), [ctx, slots, standard.wide])
550
644
  const RowBoundary = React.useMemo(() => rowBoundary(), [])
551
645
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
@@ -672,6 +766,47 @@ export function BetterWorkspace(props) {
672
766
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
673
767
  },
674
768
  }, 'Copy path'),
769
+ React.createElement('button', {
770
+ type: 'button',
771
+ 'data-polish-ws-rename': 'open',
772
+ style: WS_MENU_ITEM,
773
+ onClick: () => {
774
+ const workspace = menu.workspace
775
+ closeMenu()
776
+ setNotice('')
777
+ setRenaming({ workspaceId: workspace.workspaceId, label: workspaceLabel(workspace), value: workspaceLabel(workspace) })
778
+ },
779
+ }, 'Rename workspace…'),
780
+ React.createElement('button', {
781
+ type: 'button',
782
+ 'data-polish-order': 'remember',
783
+ style: WS_MENU_ITEM,
784
+ onClick: () => {
785
+ closeMenu()
786
+ const ids = workspaceList.map((workspace) => workspace.workspaceId)
787
+ writeOrder(ids)
788
+ setSavedOrder(ids)
789
+ setNotice('Order saved.')
790
+ },
791
+ }, 'Remember this order'),
792
+ savedOrder.length === 0
793
+ ? null
794
+ : React.createElement('button', {
795
+ type: 'button',
796
+ 'data-polish-order': 'restore',
797
+ style: WS_MENU_ITEM,
798
+ onClick: async () => {
799
+ closeMenu()
800
+ const ops = planReorder(workspaceList.map((workspace) => workspace.workspaceId), savedOrder)
801
+ if (ops.length === 0) { setNotice('Already in the saved order.'); return }
802
+ try {
803
+ for (const op of ops) await services.reorderWorkspace(op.movingId, op.beforeId)
804
+ setNotice('Order restored.')
805
+ } catch {
806
+ setNotice('Could not restore that order.')
807
+ }
808
+ },
809
+ }, 'Restore saved order'),
675
810
  React.createElement('button', {
676
811
  type: 'button',
677
812
  'data-polish-ws-appearance': 'open',
@@ -808,6 +943,26 @@ export function BetterWorkspace(props) {
808
943
  }))
809
944
  }
810
945
 
946
+ if (renaming) {
947
+ children.push(React.createElement('input', {
948
+ key: 'rename',
949
+ 'data-polish-ws-rename-input': 'true',
950
+ autoFocus: true,
951
+ value: renaming.value,
952
+ placeholder: 'Workspace name (use Work/Name to file it)',
953
+ 'aria-label': 'Workspace name',
954
+ style: WS_FILTER,
955
+ onChange: (event) => setRenaming(Object.assign({}, renaming, { value: event.target.value })),
956
+ onKeyDown: (event) => {
957
+ if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); return }
958
+ if (event.key !== 'Enter') return
959
+ event.preventDefault()
960
+ void commitRename()
961
+ },
962
+ onBlur: () => { void commitRename() },
963
+ }))
964
+ }
965
+
811
966
  if (rows.length === 0) {
812
967
  children.push(React.createElement('div', {
813
968
  key: 'empty',
@@ -816,14 +971,82 @@ export function BetterWorkspace(props) {
816
971
  }
817
972
 
818
973
  let index = -1
974
+ const announced = {}
975
+
976
+ const sessionRow = (workspace, row, groupIndex) => {
977
+ const rowFocused = cursor === groupIndex
978
+ const rowHovered = hover === 'session:' + row.id
979
+ const lifted = dropping === 'session:' + row.id
980
+ return React.createElement('div', {
981
+ key: 'session:' + row.id,
982
+ 'data-polish-ws-session': row.id,
983
+ draggable: true,
984
+ style: Object.assign({}, WS_ROW, {
985
+ paddingLeft: '24px',
986
+ opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
987
+ background: lifted
988
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
989
+ : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
990
+ boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
991
+ }),
992
+ onClick: () => services.openSession(row.id),
993
+ onContextMenu: (event) => openMenu(event, row, workspace),
994
+ onMouseEnter: () => setHover('session:' + row.id),
995
+ onMouseLeave: () => setHover(null),
996
+ onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
997
+ onDragOver: (event) => {
998
+ if (!drag || drag.kind !== 'session' || drag.id === row.id) return
999
+ event.preventDefault()
1000
+ setDropping('session:' + row.id)
1001
+ },
1002
+ onDragLeave: () => setDropping(null),
1003
+ onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
1004
+ },
1005
+ row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1006
+ pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1007
+ React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
1008
+ rowHovered || rowFocused
1009
+ ? React.createElement('button', {
1010
+ type: 'button',
1011
+ 'data-polish-ws-menu': 'session',
1012
+ 'aria-label': 'Session menu',
1013
+ style: WS_ROW_ACTION,
1014
+ onClick: (event) => openMenu(event, row, workspace),
1015
+ }, React.createElement(DotsGlyph))
1016
+ : null)
1017
+ }
1018
+
819
1019
  rows.forEach((group) => {
820
1020
  const workspace = group.workspace
821
1021
  const sessions = group.sessions
1022
+ const folder = folderOf(workspace)
1023
+ const folded = folder !== undefined && folders[folder] === true
1024
+
1025
+ if (folder !== undefined && !announced[folder]) {
1026
+ announced[folder] = true
1027
+ const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1028
+ children.push(React.createElement('div', {
1029
+ key: 'folder:' + folder,
1030
+ 'data-polish-ws-folder': folder,
1031
+ role: 'group',
1032
+ 'aria-label': folder,
1033
+ title: folded ? 'Expand ' + folder : 'Collapse ' + folder,
1034
+ style: WS_FOLDER_ROW,
1035
+ onClick: () => setFolders((prev) => Object.assign({}, prev, { [folder]: prev[folder] !== true })),
1036
+ },
1037
+ React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1038
+ React.createElement('span', { style: WS_TITLE }, folder),
1039
+ React.createElement('span', { style: WS_COUNT }, String(inside.reduce((sum, entry) => sum + entry.sessions.length, 0)))))
1040
+ }
1041
+
1042
+ if (folded) return
1043
+
822
1044
  index += 1
823
1045
  const open = expanded[workspace.workspaceId] !== false
824
1046
  const focused = cursor === index
825
1047
  const hovered = hover === 'workspace:' + workspace.workspaceId
826
1048
  const dropHere = dropping === 'workspace:' + workspace.workspaceId
1049
+ const looks = appearanceOf(look, workspace.workspaceId)
827
1050
 
828
1051
  children.push(React.createElement('div', {
829
1052
  key: 'workspace:' + workspace.workspaceId,
@@ -855,15 +1078,11 @@ export function BetterWorkspace(props) {
855
1078
  },
856
1079
  React.createElement(CaretGlyph, { open }),
857
1080
  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
- })
1081
+ looks.tone
1082
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
862
1083
  : null,
863
1084
  React.createElement('span', {
864
- style: Object.assign({}, WS_TITLE, {
865
- fontWeight: appearanceOf(look, workspace.workspaceId).strong ? 600 : undefined,
866
- }),
1085
+ style: Object.assign({}, WS_TITLE, { fontWeight: looks.strong ? 600 : undefined }),
867
1086
  }, workspaceLabel(workspace)),
868
1087
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
869
1088
  hovered || focused
@@ -879,46 +1098,7 @@ export function BetterWorkspace(props) {
879
1098
  if (open) {
880
1099
  sessions.forEach((row) => {
881
1100
  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))
1101
+ children.push(sessionRow(workspace, row, index))
922
1102
  })
923
1103
  }
924
1104
  })
@@ -1071,6 +1251,7 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
1071
1251
  reorderWorkspace: (movingId, beforeId) => seated.workspaces.insertBefore(movingId, beforeId),
1072
1252
  reorderSession: (workspaceId, movingId, beforeId) => seated.workspaces.insertSessionBefore(workspaceId, movingId, beforeId),
1073
1253
  removeWorkspace: (id) => seated.workspaces.delete(id),
1254
+ renameWorkspace: (id, title) => seated.workspaces.rename(id, title),
1074
1255
  openFolder: async (path) => {
1075
1256
  const answer = await seated.remote.session.openWorkspacePath({ path })
1076
1257
  if (answer && answer.ok === false) throw new Error('cannot open the folder')
@@ -114,3 +114,23 @@ 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.
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.13.1"
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,79 @@ 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
+
589
+ /**
590
+ * A workspace whose label carries a path ("Work/Client A") files itself under
591
+ * "Work". The grouping is purely visual and derived from the title, so nothing is
592
+ * written to the Host and renaming a workspace is enough to move it between
593
+ * folders — the same idea as the reference plugin, without its "/" toggle.
594
+ */
595
+ function folderKey(workspace) {
596
+ const label = workspaceLabel(workspace)
597
+ const cut = label.lastIndexOf('/')
598
+ if (cut <= 0 || cut === label.length - 1) return undefined
599
+ return label.slice(0, cut).trim() || undefined
600
+ }
601
+
602
+ function readOrder(storage) {
603
+ try {
604
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
605
+ if (!store) return []
606
+ const raw = store.getItem(ORDER_KEY)
607
+ if (!raw) return []
608
+ const parsed = JSON.parse(raw)
609
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
610
+ } catch {
611
+ return []
612
+ }
613
+ }
614
+
615
+ function writeOrder(ids, storage) {
616
+ try {
617
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
618
+ if (!store) return
619
+ store.setItem(ORDER_KEY, JSON.stringify(Array.isArray(ids) ? ids : []))
620
+ } catch {
621
+ /* storage is a convenience here, never a requirement */
622
+ }
623
+ }
624
+
625
+ /**
626
+ * The insertBefore calls that turn one order into another. `insertBefore(moving, before)`
627
+ * is the only reorder verb the Host offers, so the plan walks the wanted order and moves
628
+ * each workspace in front of whatever currently sits at that slot.
629
+ */
630
+ function planReorder(currentIds, wantedIds) {
631
+ const current = Array.isArray(currentIds) ? currentIds.slice() : []
632
+ const ops = []
633
+ // Only ids the Host still has can move; a saved order that names a deleted
634
+ // workspace must not shuffle the rest into the wrong slots.
635
+ const wanted = (Array.isArray(wantedIds) ? wantedIds : []).filter((id) => current.indexOf(id) !== -1)
636
+ for (let i = 0; i < wanted.length; i += 1) {
637
+ const id = wanted[i]
638
+ const from = current.indexOf(id)
639
+ if (from === -1 || from === i) continue
640
+ const beforeId = current[i]
641
+ ops.push({ movingId: id, beforeId })
642
+ current.splice(from, 1)
643
+ current.splice(i, 0, id)
644
+ }
645
+ return ops
646
+ }
647
+
648
+ const WS_FOLDER_ROW = {
649
+ display: 'flex',
650
+ alignItems: 'center',
651
+ gap: '5px',
652
+ padding: '3px 6px 2px',
653
+ fontSize: 'calc(' + 'var(--dsh-content-font-size-secondary, 13px)' + ' - 2px)',
654
+ letterSpacing: '0.02em',
655
+ textTransform: 'uppercase',
656
+ color: 'var(--dsw-alias-label-tertiary, #61666b)',
657
+ cursor: 'pointer',
658
+ userSelect: 'none',
659
+ }
587
660
 
588
661
  const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
589
662
 
@@ -725,6 +798,22 @@ window.__ModuleLoader__.load({
725
798
  const [drag, setDrag] = React.useState(null)
726
799
  const [dropping, setDropping] = React.useState(null)
727
800
  const [look, setLook] = React.useState(() => readAppearance())
801
+ const [folders, setFolders] = React.useState({})
802
+ const [renaming, setRenaming] = React.useState(null)
803
+ const commitRename = async () => {
804
+ const draft = renaming
805
+ setRenaming(null)
806
+ if (!draft || !services.renameWorkspace) return
807
+ const title = String(draft.value || '').trim()
808
+ if (title === '' || title === draft.label) return
809
+ try {
810
+ await services.renameWorkspace(draft.workspaceId, title)
811
+ setNotice('Renamed to ' + title + '.')
812
+ } catch {
813
+ setNotice('Could not rename that workspace.')
814
+ }
815
+ }
816
+ const [savedOrder, setSavedOrder] = React.useState(() => readOrder())
728
817
  const paintLook = (workspaceId, patch) => {
729
818
  setLook((prev) => {
730
819
  const next = withAppearance(prev, workspaceId, patch)
@@ -746,7 +835,12 @@ window.__ModuleLoader__.load({
746
835
  )
747
836
  const allRows = workspaceRows(workspaceList, sessionList)
748
837
  const rows = filterTree(allRows, query, pinned)
749
- const walk = flattenRows(rows, expanded, query)
838
+ const folderOf = (workspace) => folderKey(workspace)
839
+ const visibleRows = rows.filter((group) => {
840
+ const key = folderOf(group.workspace)
841
+ return key === undefined || folders[key] !== true
842
+ })
843
+ const walk = flattenRows(visibleRows, expanded, query)
750
844
  const runtime = React.useMemo(() => menuRuntime(ctx, slots, standard), [ctx, slots, standard.wide])
751
845
  const RowBoundary = React.useMemo(() => rowBoundary(), [])
752
846
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
@@ -873,6 +967,47 @@ window.__ModuleLoader__.load({
873
967
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
874
968
  },
875
969
  }, 'Copy path'),
970
+ React.createElement('button', {
971
+ type: 'button',
972
+ 'data-polish-ws-rename': 'open',
973
+ style: WS_MENU_ITEM,
974
+ onClick: () => {
975
+ const workspace = menu.workspace
976
+ closeMenu()
977
+ setNotice('')
978
+ setRenaming({ workspaceId: workspace.workspaceId, label: workspaceLabel(workspace), value: workspaceLabel(workspace) })
979
+ },
980
+ }, 'Rename workspace…'),
981
+ React.createElement('button', {
982
+ type: 'button',
983
+ 'data-polish-order': 'remember',
984
+ style: WS_MENU_ITEM,
985
+ onClick: () => {
986
+ closeMenu()
987
+ const ids = workspaceList.map((workspace) => workspace.workspaceId)
988
+ writeOrder(ids)
989
+ setSavedOrder(ids)
990
+ setNotice('Order saved.')
991
+ },
992
+ }, 'Remember this order'),
993
+ savedOrder.length === 0
994
+ ? null
995
+ : React.createElement('button', {
996
+ type: 'button',
997
+ 'data-polish-order': 'restore',
998
+ style: WS_MENU_ITEM,
999
+ onClick: async () => {
1000
+ closeMenu()
1001
+ const ops = planReorder(workspaceList.map((workspace) => workspace.workspaceId), savedOrder)
1002
+ if (ops.length === 0) { setNotice('Already in the saved order.'); return }
1003
+ try {
1004
+ for (const op of ops) await services.reorderWorkspace(op.movingId, op.beforeId)
1005
+ setNotice('Order restored.')
1006
+ } catch {
1007
+ setNotice('Could not restore that order.')
1008
+ }
1009
+ },
1010
+ }, 'Restore saved order'),
876
1011
  React.createElement('button', {
877
1012
  type: 'button',
878
1013
  'data-polish-ws-appearance': 'open',
@@ -1009,6 +1144,26 @@ window.__ModuleLoader__.load({
1009
1144
  }))
1010
1145
  }
1011
1146
 
1147
+ if (renaming) {
1148
+ children.push(React.createElement('input', {
1149
+ key: 'rename',
1150
+ 'data-polish-ws-rename-input': 'true',
1151
+ autoFocus: true,
1152
+ value: renaming.value,
1153
+ placeholder: 'Workspace name (use Work/Name to file it)',
1154
+ 'aria-label': 'Workspace name',
1155
+ style: WS_FILTER,
1156
+ onChange: (event) => setRenaming(Object.assign({}, renaming, { value: event.target.value })),
1157
+ onKeyDown: (event) => {
1158
+ if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); return }
1159
+ if (event.key !== 'Enter') return
1160
+ event.preventDefault()
1161
+ void commitRename()
1162
+ },
1163
+ onBlur: () => { void commitRename() },
1164
+ }))
1165
+ }
1166
+
1012
1167
  if (rows.length === 0) {
1013
1168
  children.push(React.createElement('div', {
1014
1169
  key: 'empty',
@@ -1017,14 +1172,82 @@ window.__ModuleLoader__.load({
1017
1172
  }
1018
1173
 
1019
1174
  let index = -1
1175
+ const announced = {}
1176
+
1177
+ const sessionRow = (workspace, row, groupIndex) => {
1178
+ const rowFocused = cursor === groupIndex
1179
+ const rowHovered = hover === 'session:' + row.id
1180
+ const lifted = dropping === 'session:' + row.id
1181
+ return React.createElement('div', {
1182
+ key: 'session:' + row.id,
1183
+ 'data-polish-ws-session': row.id,
1184
+ draggable: true,
1185
+ style: Object.assign({}, WS_ROW, {
1186
+ paddingLeft: '24px',
1187
+ opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
1188
+ background: lifted
1189
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1190
+ : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
1191
+ boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
1192
+ }),
1193
+ onClick: () => services.openSession(row.id),
1194
+ onContextMenu: (event) => openMenu(event, row, workspace),
1195
+ onMouseEnter: () => setHover('session:' + row.id),
1196
+ onMouseLeave: () => setHover(null),
1197
+ onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
1198
+ onDragOver: (event) => {
1199
+ if (!drag || drag.kind !== 'session' || drag.id === row.id) return
1200
+ event.preventDefault()
1201
+ setDropping('session:' + row.id)
1202
+ },
1203
+ onDragLeave: () => setDropping(null),
1204
+ onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
1205
+ },
1206
+ row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1207
+ pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1208
+ React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
1209
+ rowHovered || rowFocused
1210
+ ? React.createElement('button', {
1211
+ type: 'button',
1212
+ 'data-polish-ws-menu': 'session',
1213
+ 'aria-label': 'Session menu',
1214
+ style: WS_ROW_ACTION,
1215
+ onClick: (event) => openMenu(event, row, workspace),
1216
+ }, React.createElement(DotsGlyph))
1217
+ : null)
1218
+ }
1219
+
1020
1220
  rows.forEach((group) => {
1021
1221
  const workspace = group.workspace
1022
1222
  const sessions = group.sessions
1223
+ const folder = folderOf(workspace)
1224
+ const folded = folder !== undefined && folders[folder] === true
1225
+
1226
+ if (folder !== undefined && !announced[folder]) {
1227
+ announced[folder] = true
1228
+ const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1229
+ children.push(React.createElement('div', {
1230
+ key: 'folder:' + folder,
1231
+ 'data-polish-ws-folder': folder,
1232
+ role: 'group',
1233
+ 'aria-label': folder,
1234
+ title: folded ? 'Expand ' + folder : 'Collapse ' + folder,
1235
+ style: WS_FOLDER_ROW,
1236
+ onClick: () => setFolders((prev) => Object.assign({}, prev, { [folder]: prev[folder] !== true })),
1237
+ },
1238
+ React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1239
+ React.createElement('span', { style: WS_TITLE }, folder),
1240
+ React.createElement('span', { style: WS_COUNT }, String(inside.reduce((sum, entry) => sum + entry.sessions.length, 0)))))
1241
+ }
1242
+
1243
+ if (folded) return
1244
+
1023
1245
  index += 1
1024
1246
  const open = expanded[workspace.workspaceId] !== false
1025
1247
  const focused = cursor === index
1026
1248
  const hovered = hover === 'workspace:' + workspace.workspaceId
1027
1249
  const dropHere = dropping === 'workspace:' + workspace.workspaceId
1250
+ const looks = appearanceOf(look, workspace.workspaceId)
1028
1251
 
1029
1252
  children.push(React.createElement('div', {
1030
1253
  key: 'workspace:' + workspace.workspaceId,
@@ -1056,15 +1279,11 @@ window.__ModuleLoader__.load({
1056
1279
  },
1057
1280
  React.createElement(CaretGlyph, { open }),
1058
1281
  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
- })
1282
+ looks.tone
1283
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1063
1284
  : null,
1064
1285
  React.createElement('span', {
1065
- style: Object.assign({}, WS_TITLE, {
1066
- fontWeight: appearanceOf(look, workspace.workspaceId).strong ? 600 : undefined,
1067
- }),
1286
+ style: Object.assign({}, WS_TITLE, { fontWeight: looks.strong ? 600 : undefined }),
1068
1287
  }, workspaceLabel(workspace)),
1069
1288
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
1070
1289
  hovered || focused
@@ -1080,46 +1299,7 @@ window.__ModuleLoader__.load({
1080
1299
  if (open) {
1081
1300
  sessions.forEach((row) => {
1082
1301
  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))
1302
+ children.push(sessionRow(workspace, row, index))
1123
1303
  })
1124
1304
  }
1125
1305
  })
@@ -1272,6 +1452,7 @@ window.__ModuleLoader__.load({
1272
1452
  reorderWorkspace: (movingId, beforeId) => seated.workspaces.insertBefore(movingId, beforeId),
1273
1453
  reorderSession: (workspaceId, movingId, beforeId) => seated.workspaces.insertSessionBefore(workspaceId, movingId, beforeId),
1274
1454
  removeWorkspace: (id) => seated.workspaces.delete(id),
1455
+ renameWorkspace: (id, title) => seated.workspaces.rename(id, title),
1275
1456
  openFolder: async (path) => {
1276
1457
  const answer = await seated.remote.session.openWorkspacePath({ path })
1277
1458
  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.13.1",
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",