dsh-homepage 0.1.1 → 0.1.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.
@@ -117,6 +117,28 @@
117
117
  if (value === null || value === undefined) return ''
118
118
  return String(value)
119
119
  }
120
+ /**
121
+ * @param value - a workspace view, a workspace id, or anything else.
122
+ * @returns the workspace id it carries, or '' when it names none. The
123
+ * client service answers `workspaceId`; a host that answers `id` is the
124
+ * one other spelling this region accepts.
125
+ */
126
+ function sbWorkspaceId(value) {
127
+ if (typeof value === 'string') return value
128
+ if (!value || typeof value !== 'object') return ''
129
+ return sbText(value.workspaceId || value.id)
130
+ }
131
+ /**
132
+ * @param value - an ISO-8601 instant, epoch milliseconds, or anything else.
133
+ * @returns epoch milliseconds, or 0 when it names no time.
134
+ */
135
+ function sbEpoch(value) {
136
+ if (typeof value === 'number') return isFinite(value) && value > 0 ? value : 0
137
+ var text = sbText(value)
138
+ if (text === '') return 0
139
+ var parsed = Date.parse(text)
140
+ return isFinite(parsed) && parsed > 0 ? parsed : 0
141
+ }
120
142
  /** @param value - a path. @returns its last segment, both separators accepted. */
121
143
  function sbBase(value) {
122
144
  var path = sbText(value)
@@ -223,19 +245,22 @@
223
245
  return draw(size || 16)
224
246
  }
225
247
  /**
226
- * A project's own recency: the newest session it shows. A project with no
227
- * sessions stays at zero on purpose — it has nothing to be recent about, and
228
- * borrowing its neighbours' time would make it outrank a project in use.
248
+ * A project's own recency: the newest session it shows, or when the
249
+ * workspace itself was created — whichever is later. A project with no
250
+ * sessions is not an old project: registering it is an event, so it must
251
+ * not sink to the bottom of a section ordered by recency the moment it is
252
+ * added. Borrowing a neighbour's time is still wrong, which is what the
253
+ * creation instant keeps from happening.
229
254
  *
230
255
  * A blank row is skipped: it is the draft the shell holds for a workspace
231
256
  * you focused, never sent, so it is not an event and cannot make a project
232
257
  * recent. All its `updatedAt` ever says is when the draft was made.
233
258
  * @param project - a projected project.
234
- * @returns the newest `updatedAt` below it, or 0.
259
+ * @returns the newest instant the project stands for, or 0.
235
260
  */
236
261
  function sbProjectAt(project) {
262
+ var at = Number(project && project.createdAt) || 0
237
263
  var rows = Array.isArray(project && project.rows) ? project.rows : []
238
- var at = 0
239
264
  for (var i = 0; i < rows.length; i += 1) {
240
265
  if (rows[i] && rows[i].blank === true) continue
241
266
  var value = Number(rows[i] && rows[i].updatedAt) || 0
@@ -273,12 +298,19 @@
273
298
  * ungrouped bucket. Members are the projects the sidebar listed plus any
274
299
  * the document still names, so a stale assignment stays visible and
275
300
  * removable instead of silently disappearing.
301
+ *
302
+ * A member the projection has not carried yet — one this region created a
303
+ * moment ago — is drawn from the `fresh` memory instead: without it the row
304
+ * has no title and no path, so it reads as the workspace id, and it is
305
+ * marked as a stale assignment it is not.
276
306
  * @param layout - the document, or null.
277
307
  * @param items - the projected projects.
308
+ * @param fresh - `{ [id]: { title, path, createdAt } }` for just-created workspaces.
278
309
  * @returns the sections, each `{ key, title, color, items, collapsed, sort }`.
279
310
  */
280
- function sbSections(layout, items) {
311
+ function sbSections(layout, items, fresh) {
281
312
  var projects = Array.isArray(items) ? items : []
313
+ var memory = fresh && typeof fresh === 'object' ? fresh : null
282
314
  var groups = kit.sidebarGroups(layout).slice().sort(function (left, right) {
283
315
  var a = left && typeof left.order === 'number' ? left.order : 0
284
316
  var b = right && typeof right.order === 'number' ? right.order : 0
@@ -310,7 +342,16 @@
310
342
  if (known[id]) return
311
343
  var member = members[id]
312
344
  var section = member && member.group && byId[member.group] ? byId[member.group] : loose
313
- section.items.push({ id: id, title: '', path: '', sessions: 0, rows: [], orphan: true })
345
+ var made = memory && memory[id] ? memory[id] : null
346
+ section.items.push({
347
+ id: id,
348
+ title: made ? sbText(made.title) : '',
349
+ path: made ? sbText(made.path) : '',
350
+ sessions: 0,
351
+ rows: [],
352
+ createdAt: made ? Number(made.createdAt) || 0 : 0,
353
+ orphan: !made,
354
+ })
314
355
  })
315
356
  var out = sections.slice()
316
357
  if (loose.items.length > 0) out.push(loose)
@@ -937,11 +978,25 @@
937
978
  }
938
979
  }, [])
939
980
 
940
- /** Register the folder on screen as a workspace. */
941
- function adopt() {
942
- var listing = state.listing
943
- var path = sbText(listing && listing.path)
944
- if (path === '') return
981
+ /**
982
+ * The folder this dialog would register as a workspace: the path in the
983
+ * open field — a typed path is a target even before Enter has walked the
984
+ * list to it — and the level on screen when the field is closed or blank.
985
+ * Without the draft the two disagree: the operator types a folder, clicks
986
+ * the confirm, and the workspace is made in whatever level happened to be
987
+ * listed underneath, which reads as "it ignored what I typed".
988
+ * @returns an absolute path, or '' when there is nothing to name.
989
+ */
990
+ function targetPath() {
991
+ var draft = state.pathDraft === null ? '' : sbText(state.pathDraft).trim()
992
+ if (draft !== '') return draft
993
+ return sbText(state.listing && state.listing.path)
994
+ }
995
+ /**
996
+ * Register one directory as a workspace and hand it back to the caller.
997
+ * @param path - an absolute directory the Host has already accepted.
998
+ */
999
+ function commit(path) {
945
1000
  var projects = sbService('workspaces')
946
1001
  if (!projects || typeof projects.create !== 'function') {
947
1002
  patch({ error: sbT('sidebar.list.readonly'), phase: 'error' })
@@ -956,16 +1011,68 @@
956
1011
  return
957
1012
  }
958
1013
  if (!outcome || typeof outcome.then !== 'function') {
959
- onPicked()
1014
+ onPicked(outcome)
960
1015
  return
961
1016
  }
962
- outcome.then(function () {
963
- if (alive.current) onPicked()
1017
+ outcome.then(function (created) {
1018
+ if (alive.current) onPicked(created)
964
1019
  }, function (error) {
965
1020
  if (!alive.current) return
966
1021
  patch({ busy: false, phase: 'error', error: sbT('sidebar.list.failed', { reason: sbReason(error) }) })
967
1022
  })
968
1023
  }
1024
+ /**
1025
+ * The confirm action. It commits the folder it names, and a typed path is
1026
+ * read through the Host first: the listing proves the directory exists
1027
+ * and answers with its canonical spelling, which is what the workspace has
1028
+ * to store (`..`, a trailing separator, a Windows forward slash).
1029
+ */
1030
+ function adopt() {
1031
+ var target = targetPath()
1032
+ if (target === '') return
1033
+ if (state.busy) return
1034
+ if (target === sbText(state.listing && state.listing.path)) {
1035
+ commit(target)
1036
+ return
1037
+ }
1038
+ var service = sbService('uiWorkspace')
1039
+ if (!service || typeof service.listDirectory !== 'function') {
1040
+ // No browsing capability behind the field: the Host still owns
1041
+ // `create`, and it is the one that validates the path.
1042
+ commit(target)
1043
+ return
1044
+ }
1045
+ patch({ busy: true, error: '' })
1046
+ var outcome = null
1047
+ try {
1048
+ outcome = service.listDirectory(target)
1049
+ } catch (error) {
1050
+ patch({
1051
+ busy: false,
1052
+ phase: 'error',
1053
+ pathDraft: target,
1054
+ error: sbT('sidebar.list.dirFailed', { reason: sbReason(error) }),
1055
+ })
1056
+ return
1057
+ }
1058
+ if (!outcome || typeof outcome.then !== 'function') {
1059
+ var resolved = sbText(outcome && outcome.path) || target
1060
+ commit(resolved)
1061
+ return
1062
+ }
1063
+ outcome.then(function (listing) {
1064
+ if (!alive.current) return
1065
+ commit(sbText(listing && listing.path) || target)
1066
+ }, function (error) {
1067
+ if (!alive.current) return
1068
+ patch({
1069
+ busy: false,
1070
+ phase: 'error',
1071
+ pathDraft: target,
1072
+ error: sbT('sidebar.list.dirFailed', { reason: sbReason(error) }),
1073
+ })
1074
+ })
1075
+ }
969
1076
  /** Create the folder named in the field, then open it. */
970
1077
  function mint() {
971
1078
  var name = sbText(state.folder).trim()
@@ -1067,8 +1174,17 @@
1067
1174
  patch({ pathDraft: here })
1068
1175
  }, { key: 'edit', sb: 'dirEditPath', size: 14, className: 'ph-sb-dir-edit' }),
1069
1176
  ])
1177
+ // What the confirm action will register, said out loud under the path it
1178
+ // reads: a field whose contents are not the folder that will be created is
1179
+ // the trap this line exists to remove.
1180
+ var target = targetPath()
1181
+ var targetLine = target === ''
1182
+ ? null
1183
+ : h('div', { className: 'ph-sb-dir-note ph-sb-dir-target', key: 'target', 'data-sb': 'dirTarget' },
1184
+ sbT('sidebar.list.dirTarget', { path: target }))
1070
1185
  var body = h('div', { className: 'ph-sb-dir', key: 'dir' }, [
1071
1186
  pathRow,
1187
+ targetLine,
1072
1188
  state.phase === 'loading'
1073
1189
  ? h('div', { className: 'ph-sb-dir-note', key: 'loading' }, sbT('sidebar.list.dirLoading'))
1074
1190
  : null,
@@ -1134,7 +1250,9 @@
1134
1250
  ? h('div', { className: 'ph-sb-form-error', key: 'error', 'data-sb': 'dirError' }, state.error)
1135
1251
  : null,
1136
1252
  ])
1137
- var choose = sbText(listing && listing.path) !== ''
1253
+ // A typed path is a target of its own: the confirm action stays live for
1254
+ // it even when the level underneath could not be listed at all.
1255
+ var choose = target !== ''
1138
1256
  // The same footer the shipped browser draws: the two actions that change
1139
1257
  // the folder on the left, the confirm/cancel pair on the right, and the
1140
1258
  // hidden-items toggle between them — its label never shifts, because the
@@ -1312,7 +1430,18 @@
1312
1430
  var store = kit.useSidebarLayout()
1313
1431
  var layout = store.layout || null
1314
1432
  var items = Array.isArray(store.workspaces) ? store.workspaces : []
1315
- var sections = sbSections(layout, items)
1433
+ /**
1434
+ * The workspaces this region created a moment ago, keyed by id, with the
1435
+ * title, path and creation instant the Host answered with. The projection
1436
+ * that carries the same facts is published by an effect in another React
1437
+ * root, so for the first render after a create the document is all this
1438
+ * region has — and a member with no title and no path draws as its
1439
+ * workspace id, which is not the folder the reader just picked.
1440
+ */
1441
+ var freshPair = sbReact.useState({})
1442
+ var fresh = freshPair[0]
1443
+ var setFresh = freshPair[1]
1444
+ var sections = sbSections(layout, items, fresh)
1316
1445
  var current = sbText(store.current)
1317
1446
  var ready = sbKitReady()
1318
1447
 
@@ -1357,9 +1486,11 @@
1357
1486
  * The region's own directory browser, when it is up. The Host's native
1358
1487
  * chooser cannot exist for a browser connected to another address, so the
1359
1488
  * picker that is missing its Host half falls back to this dialog instead
1360
- * of reporting that the control does nothing.
1489
+ * of reporting that the control does nothing. `null` is closed and a
1490
+ * string is the group the flow started from ('' for the plain add), so the
1491
+ * workspace it makes lands where the reader asked for it.
1361
1492
  */
1362
- var pickingPair = sbReact.useState(false)
1493
+ var pickingPair = sbReact.useState(null)
1363
1494
  var picking = pickingPair[0]
1364
1495
  var setPicking = pickingPair[1]
1365
1496
 
@@ -1376,6 +1507,23 @@
1376
1507
  }
1377
1508
  }, [])
1378
1509
 
1510
+ // A workspace stops being "fresh" the moment the projection carries it:
1511
+ // from then on the Host's own title and path are what the row reads.
1512
+ sbReact.useEffect(function () {
1513
+ var listed = {}
1514
+ for (var i = 0; i < items.length; i += 1) listed[sbText(items[i] && items[i].id)] = true
1515
+ setFresh(function (live) {
1516
+ var kept = {}
1517
+ var changed = false
1518
+ for (var key in live) {
1519
+ if (!Object.prototype.hasOwnProperty.call(live, key)) continue
1520
+ if (listed[key]) { changed = true; continue }
1521
+ kept[key] = live[key]
1522
+ }
1523
+ return changed ? kept : live
1524
+ })
1525
+ }, [items])
1526
+
1379
1527
  /**
1380
1528
  * The header's narrow shape, as the stylesheet drew it. Both shapes are in
1381
1529
  * the tree and the container query hides one of them, so this is the only
@@ -1414,12 +1562,12 @@
1414
1562
  // this is the region's own card's way out; closing twice is the same as
1415
1563
  // closing once, and a dialog with no way out is the defect being fixed.
1416
1564
  sbReact.useEffect(function () {
1417
- if (!editor && !picking && !confirming) return undefined
1565
+ if (!editor && picking === null && !confirming) return undefined
1418
1566
  if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return undefined
1419
1567
  function onKey(event) {
1420
1568
  if (!event || event.key !== 'Escape') return
1421
1569
  setEditor(null)
1422
- setPicking(false)
1570
+ setPicking(null)
1423
1571
  setConfirming(null)
1424
1572
  }
1425
1573
  document.addEventListener('keydown', onKey)
@@ -1530,21 +1678,120 @@
1530
1678
  if (!projects) return null
1531
1679
  return projects.create({ path: path })
1532
1680
  }
1533
- /** @param picked - whatever the Host's picker resolved. */
1534
- function adoptPicked(picked) {
1535
- if (picked && typeof picked === 'object' && sbText(picked.id) !== '') return picked
1681
+ /**
1682
+ * What this region knows about a workspace the Host just answered with,
1683
+ * in the shape the list draws a member it has no projection for: the id
1684
+ * it files, the name and path the row needs, and when it was created.
1685
+ * @param workspace - a WorkspaceView from `workspaces.create`, or an id.
1686
+ * @returns `{ id, title, path, createdAt }`, or null when there is no id.
1687
+ */
1688
+ function freshOf(workspace) {
1689
+ var id = sbWorkspaceId(workspace)
1690
+ if (id === '') return null
1691
+ var view = workspace && typeof workspace === 'object' ? workspace : {}
1692
+ var path = sbText(view.path)
1693
+ return {
1694
+ id: id,
1695
+ // The Host titles a Workspace after its folder; a workspace it did
1696
+ // not title still has the folder to fall back to, and the id is the
1697
+ // one thing this row must never be named after.
1698
+ title: sbText(view.title).trim() || sbBase(path),
1699
+ path: path,
1700
+ createdAt: sbEpoch(view.createdAt) || Date.now(),
1701
+ }
1702
+ }
1703
+ /** @param entry - one `freshOf` record, remembered until the projection carries it. */
1704
+ function rememberFresh(entry) {
1705
+ setFresh(function (live) {
1706
+ var next = {}
1707
+ for (var key in live) {
1708
+ if (Object.prototype.hasOwnProperty.call(live, key)) next[key] = live[key]
1709
+ }
1710
+ next[entry.id] = entry
1711
+ return next
1712
+ })
1713
+ }
1714
+ /** @param workspaceId - the workspace whose remembered name is no longer wanted. */
1715
+ function forgetFresh(workspaceId) {
1716
+ var id = sbText(workspaceId)
1717
+ if (id === '') return
1718
+ setFresh(function (live) {
1719
+ if (!Object.prototype.hasOwnProperty.call(live, id)) return live
1720
+ var next = {}
1721
+ for (var key in live) {
1722
+ if (Object.prototype.hasOwnProperty.call(live, key) && key !== id) next[key] = live[key]
1723
+ }
1724
+ return next
1725
+ })
1726
+ }
1727
+ /**
1728
+ * File a workspace the reader just added into the group whose own control
1729
+ * started the flow, and unfold that group so the new row is on screen.
1730
+ * A project added from "实验室" that surfaces in the ungrouped bucket reads
1731
+ * as "it did not land in the category at all", which is the report this
1732
+ * answers.
1733
+ *
1734
+ * The member record is written even when there is no group: it is what
1735
+ * puts the row in the list at once instead of waiting for the Host's
1736
+ * projection, and the remembered name is what keeps that row from
1737
+ * appearing as its workspace id for the first render.
1738
+ * @param workspace - the workspace the Host answered with, or its id.
1739
+ * @param groupKey - the group the flow started from; '' for the plain add.
1740
+ * @returns the save promise, or null when there is nothing to file.
1741
+ */
1742
+ function fileNewWorkspace(workspace, groupKey) {
1743
+ var entry = freshOf(workspace)
1744
+ if (!entry) return null
1745
+ rememberFresh(entry)
1746
+ var key = sbText(groupKey)
1747
+ return kit.mutateSidebarLayout(function (live) {
1748
+ var next = kit.withWorkspace(live, entry.id, { group: key === '' ? null : key })
1749
+ if (key === '') return next
1750
+ var groups = kit.sidebarGroups(next)
1751
+ for (var i = 0; i < groups.length; i += 1) {
1752
+ var group = groups[i]
1753
+ if (group && sbText(group.id) === key && group.collapsed === true) {
1754
+ return kit.withGroupPatch(next, key, { collapsed: false })
1755
+ }
1756
+ }
1757
+ return next
1758
+ })
1759
+ }
1760
+ /**
1761
+ * Take whatever the Host's chooser resolved and register it, filing it
1762
+ * into the group the flow started from.
1763
+ * @param picked - the path the Host resolved, or a workspace it already made.
1764
+ * @param groupKey - the group the flow started from; '' for the plain add.
1765
+ * @returns the created workspace (or its save), or null.
1766
+ */
1767
+ function adoptPicked(picked, groupKey) {
1768
+ var home = sbText(groupKey)
1769
+ // A workspace the Host already made, as opposed to a path it resolved:
1770
+ // a path is a string, and a workspace is the view that carries its id.
1771
+ if (picked && typeof picked === 'object' && sbWorkspaceId(picked) !== '') {
1772
+ return fileNewWorkspace(picked, home)
1773
+ }
1536
1774
  var path = pickedPath(picked)
1537
1775
  if (path === '') return null
1538
- return createWorkspace(path)
1776
+ var created = createWorkspace(path)
1777
+ if (!created || typeof created.then !== 'function') {
1778
+ return fileNewWorkspace(created, home)
1779
+ }
1780
+ return created.then(function (workspace) {
1781
+ return fileNewWorkspace(workspace, home)
1782
+ })
1539
1783
  }
1540
- /** Put the region's own directory browser up. */
1541
- function openDirectoryPicker() {
1784
+ /**
1785
+ * Put the region's own directory browser up.
1786
+ * @param groupKey - the group the flow started from; '' for the plain add.
1787
+ */
1788
+ function openDirectoryPicker(groupKey) {
1542
1789
  if (!mountedRef.current) return
1543
1790
  setActionError('')
1544
1791
  setMenu(null)
1545
1792
  setEditor(null)
1546
1793
  setConfirming(null)
1547
- setPicking(true)
1794
+ setPicking(sbText(groupKey))
1548
1795
  }
1549
1796
  /**
1550
1797
  * Add a project. The Host's chooser is asked first: on a local connection
@@ -1554,21 +1801,36 @@
1554
1801
  * then opens its own browser (see `SidebarDirectoryDialog`), which lists
1555
1802
  * directories through the Host the way the shipped sidebar does, instead
1556
1803
  * of leaving the control dead.
1804
+ * @param section - the group whose control started the flow, or null.
1557
1805
  */
1558
- function newProject() {
1806
+ function newProject(section) {
1807
+ var home = sbText(section && section.key)
1559
1808
  act(function () {
1560
- var service = need('uiWorkspace', 'pickDirectory')
1561
- if (!service) return null
1809
+ var service = sbService('uiWorkspace')
1810
+ var picks = service && typeof service.pickDirectory === 'function'
1811
+ var browses = service && typeof service.listDirectory === 'function'
1812
+ if (!picks && !browses) {
1813
+ setActionError(sbT('sidebar.list.readonly'))
1814
+ return null
1815
+ }
1816
+ // A Host with listing but no chooser is exactly the remote case; the
1817
+ // region's own browser is the only interaction it can offer.
1818
+ if (!picks) {
1819
+ openDirectoryPicker(home)
1820
+ return null
1821
+ }
1562
1822
  var outcome = null
1563
1823
  try {
1564
1824
  outcome = service.pickDirectory()
1565
1825
  } catch (error) {
1566
- openDirectoryPicker()
1826
+ openDirectoryPicker(home)
1567
1827
  return null
1568
1828
  }
1569
- if (!outcome || typeof outcome.then !== 'function') return adoptPicked(outcome)
1570
- return outcome.then(adoptPicked, function () {
1571
- openDirectoryPicker()
1829
+ if (!outcome || typeof outcome.then !== 'function') return adoptPicked(outcome, home)
1830
+ return outcome.then(function (picked) {
1831
+ return adoptPicked(picked, home)
1832
+ }, function () {
1833
+ openDirectoryPicker(home)
1572
1834
  return null
1573
1835
  })
1574
1836
  })
@@ -1602,15 +1864,20 @@
1602
1864
  }
1603
1865
  /** @param project - the project to unregister. */
1604
1866
  function removeProject(project) {
1867
+ var id = sbText(project && project.id)
1605
1868
  act(function () {
1606
1869
  var service = need('workspaces', 'delete')
1607
1870
  if (!service) return null
1608
- return service.delete(sbText(project && project.id))
1871
+ // Its remembered name goes with it: the row it belongs to is about to
1872
+ // stop existing, and a later workspace never reuses the id.
1873
+ forgetFresh(id)
1874
+ return service.delete(id)
1609
1875
  })
1610
1876
  }
1611
1877
  /** @param id - a workspace the document still names but the sidebar dropped. */
1612
1878
  function forgetProject(id) {
1613
1879
  act(function () {
1880
+ forgetFresh(id)
1614
1881
  return kit.mutateSidebarLayout(function (live) { return kit.withoutWorkspace(live, id) })
1615
1882
  })
1616
1883
  }
@@ -1963,7 +2230,7 @@
1963
2230
  }
1964
2231
  /** @param choice - a group menu id. @param section - the group section. */
1965
2232
  function runGroupChoice(choice, section) {
1966
- if (choice === 'newProject') return newProject()
2233
+ if (choice === 'newProject') return newProject(section)
1967
2234
  if (choice === 'rename') return openEditor('group', sbText(section && section.key), section && section.title)
1968
2235
  if (choice.indexOf('color:') === 0) return markSection(section, choice.slice(6))
1969
2236
  if (choice === 'sort') return setSectionSort(section, section && section.sort === 'recent' ? 'manual' : 'recent')
@@ -2293,8 +2560,12 @@
2293
2560
  head.push(h('span', { className: 'ph-sb-actions', key: 'actions' }, [
2294
2561
  sbIconButton('plus', sbT('sidebar.list.newProject'), function (event) {
2295
2562
  if (event && event.stopPropagation) event.stopPropagation()
2296
- newProject()
2297
- }, { key: 'new', sb: 'newProject', disabled: !sbHas('uiWorkspace', 'pickDirectory') }),
2563
+ newProject(section)
2564
+ }, {
2565
+ key: 'new',
2566
+ sb: 'newProject',
2567
+ disabled: !sbHas('uiWorkspace', 'pickDirectory') && !sbHas('uiWorkspace', 'listDirectory'),
2568
+ }),
2298
2569
  sbMenu('group:' + key, open, anchor, entries, function (choice) { runGroupChoice(choice, section) }, closeMenu),
2299
2570
  ]))
2300
2571
  }
@@ -2501,11 +2772,15 @@
2501
2772
  cancelEditor,
2502
2773
  )
2503
2774
  }
2504
- if (picking) {
2775
+ if (picking !== null) {
2505
2776
  layer = h(SidebarDirectoryDialog, {
2506
2777
  key: 'picker',
2507
- onCancel: function () { setPicking(false) },
2508
- onPicked: function () { setPicking(false) },
2778
+ onCancel: function () { setPicking(null) },
2779
+ onPicked: function (workspace) {
2780
+ var home = picking
2781
+ setPicking(null)
2782
+ attempt(function () { return fileNewWorkspace(workspace, home) })
2783
+ },
2509
2784
  })
2510
2785
  }
2511
2786
  if (confirming) {
package/lib/client.js CHANGED
@@ -3284,10 +3284,10 @@ var GRAIN = 'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org
3284
3284
  // The region's own directory browser (the shell's `Modal` draws the card
3285
3285
  // when the kit is there; these are the rows both draw).
3286
3286
  '.ph-sb-dir{display:flex;flex-direction:column;gap:6px;min-height:180px;max-height:46vh}',
3287
- '.ph-sb-dir-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:0 2px;min-height:22px}',
3287
+ '.ph-sb-dir-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:0 2px;min-height:22px;flex:none}',
3288
3288
  // The trail turns into a path field: the shipped browser lets a reader
3289
3289
  // type an absolute path instead of walking to it.
3290
- '.ph-sb-dir-path{display:flex;align-items:center;gap:6px;min-height:22px}',
3290
+ '.ph-sb-dir-path{display:flex;align-items:center;gap:6px;min-height:22px;flex:none}',
3291
3291
  '.ph-sb-dir-path-field{flex:1 1 auto;width:auto}',
3292
3292
  '.ph-sb-dir-edit{margin-left:auto}',
3293
3293
  '.ph-sb-dir-seat{display:inline-flex;align-items:center;gap:2px}',
@@ -3303,7 +3303,11 @@ var GRAIN = 'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org
3303
3303
  '.ph-sb-dir-crumb:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ' +
3304
3304
  'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#3b6fe0));outline-offset:1px}',
3305
3305
  '.ph-sb-dir-crumb.is-current{color:var(--dsw-alias-label-primary,#e6e8ea)}',
3306
- '.ph-sb-dir-list{display:flex;flex-direction:column;gap:1px;min-height:0;overflow:auto}',
3306
+ // The list is the one child that gives way when the dialog hits its
3307
+ // height: a row of text shrunk to fit its box, which is how the target
3308
+ // line lost its lower half. Every other child keeps its height, and the
3309
+ // list scrolls instead.
3310
+ '.ph-sb-dir-list{display:flex;flex-direction:column;gap:1px;min-height:0;overflow:auto;flex:1 1 auto}',
3307
3311
  '.ph-sb-dir-row{display:flex;align-items:center;gap:4px;width:100%;box-sizing:border-box;height:28px;padding:0 4px;' +
3308
3312
  'border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;cursor:pointer;text-align:left;' +
3309
3313
  'font-family:inherit;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#e6e8ea)}',
@@ -3315,8 +3319,18 @@ var GRAIN = 'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org
3315
3319
  '.ph-sb-dir-row.is-hidden{opacity:.62}',
3316
3320
  '.ph-sb-dir-icon{flex:none;display:inline-flex;opacity:.7}',
3317
3321
  '.ph-sb-dir-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
3318
- '.ph-sb-dir-note{padding:4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#9aa4b2)}',
3319
- '.ph-sb-dir-newfold{display:flex;align-items:center;gap:6px}',
3322
+ '.ph-sb-dir-note{padding:4px;font-size:12px;line-height:18px;flex:none;' +
3323
+ 'color:var(--dsw-alias-label-secondary,#9aa4b2)}',
3324
+ // The target is a path, and the part of it that tells two folders apart is
3325
+ // the end: it wraps rather than ellipsizing the folder away, and it never
3326
+ // gives up height to the scrolling list below it (a clipped line is what a
3327
+ // shrunk flex item with `overflow:hidden` looks like).
3328
+ '.ph-sb-dir-target{flex:none;overflow-wrap:anywhere;' +
3329
+ 'color:var(--dsw-alias-label-primary,#e6e8ea)}',
3330
+ '.ph-sb-dir-newfold{display:flex;align-items:center;gap:6px;flex:none}',
3331
+ // The error line sits in the same column: it is the one thing the reader
3332
+ // must be able to read, so it keeps its height like the notes above it.
3333
+ '.ph-sb-dir .ph-sb-form-error{flex:none}',
3320
3334
  '.ph-sb-dir-newfold .ph-sb-field{flex:1 1 auto}',
3321
3335
  // The footer toggle from the shipped browser: plain text at control size,
3322
3336
  // no border, and the pressed state marked by a trailing check so the
@@ -4938,6 +4952,7 @@ var STYLES = [
4938
4952
  'sidebar.list.dirEmpty': '没有子文件夹',
4939
4953
  'sidebar.list.dirHiddenOnly': '没有可见的子文件夹,隐藏项已折叠',
4940
4954
  'sidebar.list.dirTruncated': '文件夹太多,只列出了前一部分',
4955
+ 'sidebar.list.dirTarget': '将在此创建工作区:{path}',
4941
4956
  'sidebar.list.dirFailed': '读不到这个目录:{reason}',
4942
4957
  'sidebar.list.apply': '确定',
4943
4958
  'sidebar.list.more': '更多',
@@ -5168,6 +5183,7 @@ var STYLES = [
5168
5183
  'sidebar.list.dirEmpty': 'No subfolders',
5169
5184
  'sidebar.list.dirHiddenOnly': 'No visible subfolders; hidden items are folded',
5170
5185
  'sidebar.list.dirTruncated': 'Too many folders; only the first ones are listed',
5186
+ 'sidebar.list.dirTarget': 'Creates the workspace in: {path}',
5171
5187
  'sidebar.list.dirFailed': 'Cannot read this folder: {reason}',
5172
5188
  'sidebar.list.apply': 'Apply',
5173
5189
  'sidebar.list.more': 'More',
@@ -5401,6 +5417,20 @@ var STYLES = [
5401
5417
  updatedAt: Number(row.updatedAt) || 0,
5402
5418
  }
5403
5419
  }
5420
+ /**
5421
+ * Read one workspace instant: the Host stamps Workspace rows with ISO-8601
5422
+ * strings, while a projection that was already reduced can carry epoch
5423
+ * milliseconds. Both are wanted by the list, which orders by them.
5424
+ * @param value - an ISO-8601 instant, epoch milliseconds, or anything else.
5425
+ * @returns epoch milliseconds, or 0 when it names no time.
5426
+ */
5427
+ function epochOf(value) {
5428
+ if (typeof value === 'number') return isFinite(value) && value > 0 ? value : 0
5429
+ var text = String(value === undefined || value === null ? '' : value)
5430
+ if (text === '') return 0
5431
+ var parsed = Date.parse(text)
5432
+ return isFinite(parsed) && parsed > 0 ? parsed : 0
5433
+ }
5404
5434
  /**
5405
5435
  * @param value - a workspace projection array.
5406
5436
  * @returns a clean projection array.
@@ -5415,6 +5445,10 @@ var STYLES = [
5415
5445
  title: String(item.title === undefined || item.title === null ? '' : item.title),
5416
5446
  path: String(item.path === undefined || item.path === null ? '' : item.path),
5417
5447
  sessions: rows.length || (Number(item.sessions) || 0),
5448
+ // When the Workspace itself was registered. A project with no Sessions
5449
+ // is not old — creating it is an event — so the list's recency order
5450
+ // counts this alongside its newest Session.
5451
+ createdAt: epochOf(item.createdAt),
5418
5452
  rows: rows,
5419
5453
  }
5420
5454
  }).filter(function (entry) { return entry.id !== '' })
@@ -5598,6 +5632,7 @@ var STYLES = [
5598
5632
  id: view.workspaceId,
5599
5633
  title: view.title,
5600
5634
  path: view.path,
5635
+ createdAt: epochOf(view.createdAt),
5601
5636
  rows: rows,
5602
5637
  }
5603
5638
  })
@@ -5657,7 +5692,7 @@ var STYLES = [
5657
5692
  var parts = [String(current || ''), Object.keys(subagents).sort().join(',')]
5658
5693
  for (var i = 0; i < items.length; i += 1) {
5659
5694
  var item = items[i]
5660
- parts.push(item.id, item.title, item.path, String(item.sessions))
5695
+ parts.push(item.id, item.title, item.path, String(item.sessions), String(item.createdAt))
5661
5696
  for (var j = 0; j < item.rows.length; j += 1) {
5662
5697
  var row = item.rows[j]
5663
5698
  parts.push(
@@ -10579,6 +10614,28 @@ var STYLES = [
10579
10614
  if (value === null || value === undefined) return ''
10580
10615
  return String(value)
10581
10616
  }
10617
+ /**
10618
+ * @param value - a workspace view, a workspace id, or anything else.
10619
+ * @returns the workspace id it carries, or '' when it names none. The
10620
+ * client service answers `workspaceId`; a host that answers `id` is the
10621
+ * one other spelling this region accepts.
10622
+ */
10623
+ function sbWorkspaceId(value) {
10624
+ if (typeof value === 'string') return value
10625
+ if (!value || typeof value !== 'object') return ''
10626
+ return sbText(value.workspaceId || value.id)
10627
+ }
10628
+ /**
10629
+ * @param value - an ISO-8601 instant, epoch milliseconds, or anything else.
10630
+ * @returns epoch milliseconds, or 0 when it names no time.
10631
+ */
10632
+ function sbEpoch(value) {
10633
+ if (typeof value === 'number') return isFinite(value) && value > 0 ? value : 0
10634
+ var text = sbText(value)
10635
+ if (text === '') return 0
10636
+ var parsed = Date.parse(text)
10637
+ return isFinite(parsed) && parsed > 0 ? parsed : 0
10638
+ }
10582
10639
  /** @param value - a path. @returns its last segment, both separators accepted. */
10583
10640
  function sbBase(value) {
10584
10641
  var path = sbText(value)
@@ -10685,19 +10742,22 @@ var STYLES = [
10685
10742
  return draw(size || 16)
10686
10743
  }
10687
10744
  /**
10688
- * A project's own recency: the newest session it shows. A project with no
10689
- * sessions stays at zero on purpose — it has nothing to be recent about, and
10690
- * borrowing its neighbours' time would make it outrank a project in use.
10745
+ * A project's own recency: the newest session it shows, or when the
10746
+ * workspace itself was created — whichever is later. A project with no
10747
+ * sessions is not an old project: registering it is an event, so it must
10748
+ * not sink to the bottom of a section ordered by recency the moment it is
10749
+ * added. Borrowing a neighbour's time is still wrong, which is what the
10750
+ * creation instant keeps from happening.
10691
10751
  *
10692
10752
  * A blank row is skipped: it is the draft the shell holds for a workspace
10693
10753
  * you focused, never sent, so it is not an event and cannot make a project
10694
10754
  * recent. All its `updatedAt` ever says is when the draft was made.
10695
10755
  * @param project - a projected project.
10696
- * @returns the newest `updatedAt` below it, or 0.
10756
+ * @returns the newest instant the project stands for, or 0.
10697
10757
  */
10698
10758
  function sbProjectAt(project) {
10759
+ var at = Number(project && project.createdAt) || 0
10699
10760
  var rows = Array.isArray(project && project.rows) ? project.rows : []
10700
- var at = 0
10701
10761
  for (var i = 0; i < rows.length; i += 1) {
10702
10762
  if (rows[i] && rows[i].blank === true) continue
10703
10763
  var value = Number(rows[i] && rows[i].updatedAt) || 0
@@ -10735,12 +10795,19 @@ var STYLES = [
10735
10795
  * ungrouped bucket. Members are the projects the sidebar listed plus any
10736
10796
  * the document still names, so a stale assignment stays visible and
10737
10797
  * removable instead of silently disappearing.
10798
+ *
10799
+ * A member the projection has not carried yet — one this region created a
10800
+ * moment ago — is drawn from the `fresh` memory instead: without it the row
10801
+ * has no title and no path, so it reads as the workspace id, and it is
10802
+ * marked as a stale assignment it is not.
10738
10803
  * @param layout - the document, or null.
10739
10804
  * @param items - the projected projects.
10805
+ * @param fresh - `{ [id]: { title, path, createdAt } }` for just-created workspaces.
10740
10806
  * @returns the sections, each `{ key, title, color, items, collapsed, sort }`.
10741
10807
  */
10742
- function sbSections(layout, items) {
10808
+ function sbSections(layout, items, fresh) {
10743
10809
  var projects = Array.isArray(items) ? items : []
10810
+ var memory = fresh && typeof fresh === 'object' ? fresh : null
10744
10811
  var groups = kit.sidebarGroups(layout).slice().sort(function (left, right) {
10745
10812
  var a = left && typeof left.order === 'number' ? left.order : 0
10746
10813
  var b = right && typeof right.order === 'number' ? right.order : 0
@@ -10772,7 +10839,16 @@ var STYLES = [
10772
10839
  if (known[id]) return
10773
10840
  var member = members[id]
10774
10841
  var section = member && member.group && byId[member.group] ? byId[member.group] : loose
10775
- section.items.push({ id: id, title: '', path: '', sessions: 0, rows: [], orphan: true })
10842
+ var made = memory && memory[id] ? memory[id] : null
10843
+ section.items.push({
10844
+ id: id,
10845
+ title: made ? sbText(made.title) : '',
10846
+ path: made ? sbText(made.path) : '',
10847
+ sessions: 0,
10848
+ rows: [],
10849
+ createdAt: made ? Number(made.createdAt) || 0 : 0,
10850
+ orphan: !made,
10851
+ })
10776
10852
  })
10777
10853
  var out = sections.slice()
10778
10854
  if (loose.items.length > 0) out.push(loose)
@@ -11399,11 +11475,25 @@ var STYLES = [
11399
11475
  }
11400
11476
  }, [])
11401
11477
 
11402
- /** Register the folder on screen as a workspace. */
11403
- function adopt() {
11404
- var listing = state.listing
11405
- var path = sbText(listing && listing.path)
11406
- if (path === '') return
11478
+ /**
11479
+ * The folder this dialog would register as a workspace: the path in the
11480
+ * open field — a typed path is a target even before Enter has walked the
11481
+ * list to it — and the level on screen when the field is closed or blank.
11482
+ * Without the draft the two disagree: the operator types a folder, clicks
11483
+ * the confirm, and the workspace is made in whatever level happened to be
11484
+ * listed underneath, which reads as "it ignored what I typed".
11485
+ * @returns an absolute path, or '' when there is nothing to name.
11486
+ */
11487
+ function targetPath() {
11488
+ var draft = state.pathDraft === null ? '' : sbText(state.pathDraft).trim()
11489
+ if (draft !== '') return draft
11490
+ return sbText(state.listing && state.listing.path)
11491
+ }
11492
+ /**
11493
+ * Register one directory as a workspace and hand it back to the caller.
11494
+ * @param path - an absolute directory the Host has already accepted.
11495
+ */
11496
+ function commit(path) {
11407
11497
  var projects = sbService('workspaces')
11408
11498
  if (!projects || typeof projects.create !== 'function') {
11409
11499
  patch({ error: sbT('sidebar.list.readonly'), phase: 'error' })
@@ -11418,16 +11508,68 @@ var STYLES = [
11418
11508
  return
11419
11509
  }
11420
11510
  if (!outcome || typeof outcome.then !== 'function') {
11421
- onPicked()
11511
+ onPicked(outcome)
11422
11512
  return
11423
11513
  }
11424
- outcome.then(function () {
11425
- if (alive.current) onPicked()
11514
+ outcome.then(function (created) {
11515
+ if (alive.current) onPicked(created)
11426
11516
  }, function (error) {
11427
11517
  if (!alive.current) return
11428
11518
  patch({ busy: false, phase: 'error', error: sbT('sidebar.list.failed', { reason: sbReason(error) }) })
11429
11519
  })
11430
11520
  }
11521
+ /**
11522
+ * The confirm action. It commits the folder it names, and a typed path is
11523
+ * read through the Host first: the listing proves the directory exists
11524
+ * and answers with its canonical spelling, which is what the workspace has
11525
+ * to store (`..`, a trailing separator, a Windows forward slash).
11526
+ */
11527
+ function adopt() {
11528
+ var target = targetPath()
11529
+ if (target === '') return
11530
+ if (state.busy) return
11531
+ if (target === sbText(state.listing && state.listing.path)) {
11532
+ commit(target)
11533
+ return
11534
+ }
11535
+ var service = sbService('uiWorkspace')
11536
+ if (!service || typeof service.listDirectory !== 'function') {
11537
+ // No browsing capability behind the field: the Host still owns
11538
+ // `create`, and it is the one that validates the path.
11539
+ commit(target)
11540
+ return
11541
+ }
11542
+ patch({ busy: true, error: '' })
11543
+ var outcome = null
11544
+ try {
11545
+ outcome = service.listDirectory(target)
11546
+ } catch (error) {
11547
+ patch({
11548
+ busy: false,
11549
+ phase: 'error',
11550
+ pathDraft: target,
11551
+ error: sbT('sidebar.list.dirFailed', { reason: sbReason(error) }),
11552
+ })
11553
+ return
11554
+ }
11555
+ if (!outcome || typeof outcome.then !== 'function') {
11556
+ var resolved = sbText(outcome && outcome.path) || target
11557
+ commit(resolved)
11558
+ return
11559
+ }
11560
+ outcome.then(function (listing) {
11561
+ if (!alive.current) return
11562
+ commit(sbText(listing && listing.path) || target)
11563
+ }, function (error) {
11564
+ if (!alive.current) return
11565
+ patch({
11566
+ busy: false,
11567
+ phase: 'error',
11568
+ pathDraft: target,
11569
+ error: sbT('sidebar.list.dirFailed', { reason: sbReason(error) }),
11570
+ })
11571
+ })
11572
+ }
11431
11573
  /** Create the folder named in the field, then open it. */
11432
11574
  function mint() {
11433
11575
  var name = sbText(state.folder).trim()
@@ -11529,8 +11671,17 @@ var STYLES = [
11529
11671
  patch({ pathDraft: here })
11530
11672
  }, { key: 'edit', sb: 'dirEditPath', size: 14, className: 'ph-sb-dir-edit' }),
11531
11673
  ])
11674
+ // What the confirm action will register, said out loud under the path it
11675
+ // reads: a field whose contents are not the folder that will be created is
11676
+ // the trap this line exists to remove.
11677
+ var target = targetPath()
11678
+ var targetLine = target === ''
11679
+ ? null
11680
+ : h('div', { className: 'ph-sb-dir-note ph-sb-dir-target', key: 'target', 'data-sb': 'dirTarget' },
11681
+ sbT('sidebar.list.dirTarget', { path: target }))
11532
11682
  var body = h('div', { className: 'ph-sb-dir', key: 'dir' }, [
11533
11683
  pathRow,
11684
+ targetLine,
11534
11685
  state.phase === 'loading'
11535
11686
  ? h('div', { className: 'ph-sb-dir-note', key: 'loading' }, sbT('sidebar.list.dirLoading'))
11536
11687
  : null,
@@ -11596,7 +11747,9 @@ var STYLES = [
11596
11747
  ? h('div', { className: 'ph-sb-form-error', key: 'error', 'data-sb': 'dirError' }, state.error)
11597
11748
  : null,
11598
11749
  ])
11599
- var choose = sbText(listing && listing.path) !== ''
11750
+ // A typed path is a target of its own: the confirm action stays live for
11751
+ // it even when the level underneath could not be listed at all.
11752
+ var choose = target !== ''
11600
11753
  // The same footer the shipped browser draws: the two actions that change
11601
11754
  // the folder on the left, the confirm/cancel pair on the right, and the
11602
11755
  // hidden-items toggle between them — its label never shifts, because the
@@ -11774,7 +11927,18 @@ var STYLES = [
11774
11927
  var store = kit.useSidebarLayout()
11775
11928
  var layout = store.layout || null
11776
11929
  var items = Array.isArray(store.workspaces) ? store.workspaces : []
11777
- var sections = sbSections(layout, items)
11930
+ /**
11931
+ * The workspaces this region created a moment ago, keyed by id, with the
11932
+ * title, path and creation instant the Host answered with. The projection
11933
+ * that carries the same facts is published by an effect in another React
11934
+ * root, so for the first render after a create the document is all this
11935
+ * region has — and a member with no title and no path draws as its
11936
+ * workspace id, which is not the folder the reader just picked.
11937
+ */
11938
+ var freshPair = sbReact.useState({})
11939
+ var fresh = freshPair[0]
11940
+ var setFresh = freshPair[1]
11941
+ var sections = sbSections(layout, items, fresh)
11778
11942
  var current = sbText(store.current)
11779
11943
  var ready = sbKitReady()
11780
11944
 
@@ -11819,9 +11983,11 @@ var STYLES = [
11819
11983
  * The region's own directory browser, when it is up. The Host's native
11820
11984
  * chooser cannot exist for a browser connected to another address, so the
11821
11985
  * picker that is missing its Host half falls back to this dialog instead
11822
- * of reporting that the control does nothing.
11986
+ * of reporting that the control does nothing. `null` is closed and a
11987
+ * string is the group the flow started from ('' for the plain add), so the
11988
+ * workspace it makes lands where the reader asked for it.
11823
11989
  */
11824
- var pickingPair = sbReact.useState(false)
11990
+ var pickingPair = sbReact.useState(null)
11825
11991
  var picking = pickingPair[0]
11826
11992
  var setPicking = pickingPair[1]
11827
11993
 
@@ -11838,6 +12004,23 @@ var STYLES = [
11838
12004
  }
11839
12005
  }, [])
11840
12006
 
12007
+ // A workspace stops being "fresh" the moment the projection carries it:
12008
+ // from then on the Host's own title and path are what the row reads.
12009
+ sbReact.useEffect(function () {
12010
+ var listed = {}
12011
+ for (var i = 0; i < items.length; i += 1) listed[sbText(items[i] && items[i].id)] = true
12012
+ setFresh(function (live) {
12013
+ var kept = {}
12014
+ var changed = false
12015
+ for (var key in live) {
12016
+ if (!Object.prototype.hasOwnProperty.call(live, key)) continue
12017
+ if (listed[key]) { changed = true; continue }
12018
+ kept[key] = live[key]
12019
+ }
12020
+ return changed ? kept : live
12021
+ })
12022
+ }, [items])
12023
+
11841
12024
  /**
11842
12025
  * The header's narrow shape, as the stylesheet drew it. Both shapes are in
11843
12026
  * the tree and the container query hides one of them, so this is the only
@@ -11876,12 +12059,12 @@ var STYLES = [
11876
12059
  // this is the region's own card's way out; closing twice is the same as
11877
12060
  // closing once, and a dialog with no way out is the defect being fixed.
11878
12061
  sbReact.useEffect(function () {
11879
- if (!editor && !picking && !confirming) return undefined
12062
+ if (!editor && picking === null && !confirming) return undefined
11880
12063
  if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return undefined
11881
12064
  function onKey(event) {
11882
12065
  if (!event || event.key !== 'Escape') return
11883
12066
  setEditor(null)
11884
- setPicking(false)
12067
+ setPicking(null)
11885
12068
  setConfirming(null)
11886
12069
  }
11887
12070
  document.addEventListener('keydown', onKey)
@@ -11992,21 +12175,120 @@ var STYLES = [
11992
12175
  if (!projects) return null
11993
12176
  return projects.create({ path: path })
11994
12177
  }
11995
- /** @param picked - whatever the Host's picker resolved. */
11996
- function adoptPicked(picked) {
11997
- if (picked && typeof picked === 'object' && sbText(picked.id) !== '') return picked
12178
+ /**
12179
+ * What this region knows about a workspace the Host just answered with,
12180
+ * in the shape the list draws a member it has no projection for: the id
12181
+ * it files, the name and path the row needs, and when it was created.
12182
+ * @param workspace - a WorkspaceView from `workspaces.create`, or an id.
12183
+ * @returns `{ id, title, path, createdAt }`, or null when there is no id.
12184
+ */
12185
+ function freshOf(workspace) {
12186
+ var id = sbWorkspaceId(workspace)
12187
+ if (id === '') return null
12188
+ var view = workspace && typeof workspace === 'object' ? workspace : {}
12189
+ var path = sbText(view.path)
12190
+ return {
12191
+ id: id,
12192
+ // The Host titles a Workspace after its folder; a workspace it did
12193
+ // not title still has the folder to fall back to, and the id is the
12194
+ // one thing this row must never be named after.
12195
+ title: sbText(view.title).trim() || sbBase(path),
12196
+ path: path,
12197
+ createdAt: sbEpoch(view.createdAt) || Date.now(),
12198
+ }
12199
+ }
12200
+ /** @param entry - one `freshOf` record, remembered until the projection carries it. */
12201
+ function rememberFresh(entry) {
12202
+ setFresh(function (live) {
12203
+ var next = {}
12204
+ for (var key in live) {
12205
+ if (Object.prototype.hasOwnProperty.call(live, key)) next[key] = live[key]
12206
+ }
12207
+ next[entry.id] = entry
12208
+ return next
12209
+ })
12210
+ }
12211
+ /** @param workspaceId - the workspace whose remembered name is no longer wanted. */
12212
+ function forgetFresh(workspaceId) {
12213
+ var id = sbText(workspaceId)
12214
+ if (id === '') return
12215
+ setFresh(function (live) {
12216
+ if (!Object.prototype.hasOwnProperty.call(live, id)) return live
12217
+ var next = {}
12218
+ for (var key in live) {
12219
+ if (Object.prototype.hasOwnProperty.call(live, key) && key !== id) next[key] = live[key]
12220
+ }
12221
+ return next
12222
+ })
12223
+ }
12224
+ /**
12225
+ * File a workspace the reader just added into the group whose own control
12226
+ * started the flow, and unfold that group so the new row is on screen.
12227
+ * A project added from "实验室" that surfaces in the ungrouped bucket reads
12228
+ * as "it did not land in the category at all", which is the report this
12229
+ * answers.
12230
+ *
12231
+ * The member record is written even when there is no group: it is what
12232
+ * puts the row in the list at once instead of waiting for the Host's
12233
+ * projection, and the remembered name is what keeps that row from
12234
+ * appearing as its workspace id for the first render.
12235
+ * @param workspace - the workspace the Host answered with, or its id.
12236
+ * @param groupKey - the group the flow started from; '' for the plain add.
12237
+ * @returns the save promise, or null when there is nothing to file.
12238
+ */
12239
+ function fileNewWorkspace(workspace, groupKey) {
12240
+ var entry = freshOf(workspace)
12241
+ if (!entry) return null
12242
+ rememberFresh(entry)
12243
+ var key = sbText(groupKey)
12244
+ return kit.mutateSidebarLayout(function (live) {
12245
+ var next = kit.withWorkspace(live, entry.id, { group: key === '' ? null : key })
12246
+ if (key === '') return next
12247
+ var groups = kit.sidebarGroups(next)
12248
+ for (var i = 0; i < groups.length; i += 1) {
12249
+ var group = groups[i]
12250
+ if (group && sbText(group.id) === key && group.collapsed === true) {
12251
+ return kit.withGroupPatch(next, key, { collapsed: false })
12252
+ }
12253
+ }
12254
+ return next
12255
+ })
12256
+ }
12257
+ /**
12258
+ * Take whatever the Host's chooser resolved and register it, filing it
12259
+ * into the group the flow started from.
12260
+ * @param picked - the path the Host resolved, or a workspace it already made.
12261
+ * @param groupKey - the group the flow started from; '' for the plain add.
12262
+ * @returns the created workspace (or its save), or null.
12263
+ */
12264
+ function adoptPicked(picked, groupKey) {
12265
+ var home = sbText(groupKey)
12266
+ // A workspace the Host already made, as opposed to a path it resolved:
12267
+ // a path is a string, and a workspace is the view that carries its id.
12268
+ if (picked && typeof picked === 'object' && sbWorkspaceId(picked) !== '') {
12269
+ return fileNewWorkspace(picked, home)
12270
+ }
11998
12271
  var path = pickedPath(picked)
11999
12272
  if (path === '') return null
12000
- return createWorkspace(path)
12273
+ var created = createWorkspace(path)
12274
+ if (!created || typeof created.then !== 'function') {
12275
+ return fileNewWorkspace(created, home)
12276
+ }
12277
+ return created.then(function (workspace) {
12278
+ return fileNewWorkspace(workspace, home)
12279
+ })
12001
12280
  }
12002
- /** Put the region's own directory browser up. */
12003
- function openDirectoryPicker() {
12281
+ /**
12282
+ * Put the region's own directory browser up.
12283
+ * @param groupKey - the group the flow started from; '' for the plain add.
12284
+ */
12285
+ function openDirectoryPicker(groupKey) {
12004
12286
  if (!mountedRef.current) return
12005
12287
  setActionError('')
12006
12288
  setMenu(null)
12007
12289
  setEditor(null)
12008
12290
  setConfirming(null)
12009
- setPicking(true)
12291
+ setPicking(sbText(groupKey))
12010
12292
  }
12011
12293
  /**
12012
12294
  * Add a project. The Host's chooser is asked first: on a local connection
@@ -12016,21 +12298,36 @@ var STYLES = [
12016
12298
  * then opens its own browser (see `SidebarDirectoryDialog`), which lists
12017
12299
  * directories through the Host the way the shipped sidebar does, instead
12018
12300
  * of leaving the control dead.
12301
+ * @param section - the group whose control started the flow, or null.
12019
12302
  */
12020
- function newProject() {
12303
+ function newProject(section) {
12304
+ var home = sbText(section && section.key)
12021
12305
  act(function () {
12022
- var service = need('uiWorkspace', 'pickDirectory')
12023
- if (!service) return null
12306
+ var service = sbService('uiWorkspace')
12307
+ var picks = service && typeof service.pickDirectory === 'function'
12308
+ var browses = service && typeof service.listDirectory === 'function'
12309
+ if (!picks && !browses) {
12310
+ setActionError(sbT('sidebar.list.readonly'))
12311
+ return null
12312
+ }
12313
+ // A Host with listing but no chooser is exactly the remote case; the
12314
+ // region's own browser is the only interaction it can offer.
12315
+ if (!picks) {
12316
+ openDirectoryPicker(home)
12317
+ return null
12318
+ }
12024
12319
  var outcome = null
12025
12320
  try {
12026
12321
  outcome = service.pickDirectory()
12027
12322
  } catch (error) {
12028
- openDirectoryPicker()
12323
+ openDirectoryPicker(home)
12029
12324
  return null
12030
12325
  }
12031
- if (!outcome || typeof outcome.then !== 'function') return adoptPicked(outcome)
12032
- return outcome.then(adoptPicked, function () {
12033
- openDirectoryPicker()
12326
+ if (!outcome || typeof outcome.then !== 'function') return adoptPicked(outcome, home)
12327
+ return outcome.then(function (picked) {
12328
+ return adoptPicked(picked, home)
12329
+ }, function () {
12330
+ openDirectoryPicker(home)
12034
12331
  return null
12035
12332
  })
12036
12333
  })
@@ -12064,15 +12361,20 @@ var STYLES = [
12064
12361
  }
12065
12362
  /** @param project - the project to unregister. */
12066
12363
  function removeProject(project) {
12364
+ var id = sbText(project && project.id)
12067
12365
  act(function () {
12068
12366
  var service = need('workspaces', 'delete')
12069
12367
  if (!service) return null
12070
- return service.delete(sbText(project && project.id))
12368
+ // Its remembered name goes with it: the row it belongs to is about to
12369
+ // stop existing, and a later workspace never reuses the id.
12370
+ forgetFresh(id)
12371
+ return service.delete(id)
12071
12372
  })
12072
12373
  }
12073
12374
  /** @param id - a workspace the document still names but the sidebar dropped. */
12074
12375
  function forgetProject(id) {
12075
12376
  act(function () {
12377
+ forgetFresh(id)
12076
12378
  return kit.mutateSidebarLayout(function (live) { return kit.withoutWorkspace(live, id) })
12077
12379
  })
12078
12380
  }
@@ -12425,7 +12727,7 @@ var STYLES = [
12425
12727
  }
12426
12728
  /** @param choice - a group menu id. @param section - the group section. */
12427
12729
  function runGroupChoice(choice, section) {
12428
- if (choice === 'newProject') return newProject()
12730
+ if (choice === 'newProject') return newProject(section)
12429
12731
  if (choice === 'rename') return openEditor('group', sbText(section && section.key), section && section.title)
12430
12732
  if (choice.indexOf('color:') === 0) return markSection(section, choice.slice(6))
12431
12733
  if (choice === 'sort') return setSectionSort(section, section && section.sort === 'recent' ? 'manual' : 'recent')
@@ -12755,8 +13057,12 @@ var STYLES = [
12755
13057
  head.push(h('span', { className: 'ph-sb-actions', key: 'actions' }, [
12756
13058
  sbIconButton('plus', sbT('sidebar.list.newProject'), function (event) {
12757
13059
  if (event && event.stopPropagation) event.stopPropagation()
12758
- newProject()
12759
- }, { key: 'new', sb: 'newProject', disabled: !sbHas('uiWorkspace', 'pickDirectory') }),
13060
+ newProject(section)
13061
+ }, {
13062
+ key: 'new',
13063
+ sb: 'newProject',
13064
+ disabled: !sbHas('uiWorkspace', 'pickDirectory') && !sbHas('uiWorkspace', 'listDirectory'),
13065
+ }),
12760
13066
  sbMenu('group:' + key, open, anchor, entries, function (choice) { runGroupChoice(choice, section) }, closeMenu),
12761
13067
  ]))
12762
13068
  }
@@ -12963,11 +13269,15 @@ var STYLES = [
12963
13269
  cancelEditor,
12964
13270
  )
12965
13271
  }
12966
- if (picking) {
13272
+ if (picking !== null) {
12967
13273
  layer = h(SidebarDirectoryDialog, {
12968
13274
  key: 'picker',
12969
- onCancel: function () { setPicking(false) },
12970
- onPicked: function () { setPicking(false) },
13275
+ onCancel: function () { setPicking(null) },
13276
+ onPicked: function (workspace) {
13277
+ var home = picking
13278
+ setPicking(null)
13279
+ attempt(function () { return fileNewWorkspace(workspace, home) })
13280
+ },
12971
13281
  })
12972
13282
  }
12973
13283
  if (confirming) {
@@ -3272,10 +3272,10 @@ var GRAIN = 'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org
3272
3272
  // The region's own directory browser (the shell's `Modal` draws the card
3273
3273
  // when the kit is there; these are the rows both draw).
3274
3274
  '.ph-sb-dir{display:flex;flex-direction:column;gap:6px;min-height:180px;max-height:46vh}',
3275
- '.ph-sb-dir-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:0 2px;min-height:22px}',
3275
+ '.ph-sb-dir-crumbs{display:flex;align-items:center;flex-wrap:wrap;gap:0 2px;min-height:22px;flex:none}',
3276
3276
  // The trail turns into a path field: the shipped browser lets a reader
3277
3277
  // type an absolute path instead of walking to it.
3278
- '.ph-sb-dir-path{display:flex;align-items:center;gap:6px;min-height:22px}',
3278
+ '.ph-sb-dir-path{display:flex;align-items:center;gap:6px;min-height:22px;flex:none}',
3279
3279
  '.ph-sb-dir-path-field{flex:1 1 auto;width:auto}',
3280
3280
  '.ph-sb-dir-edit{margin-left:auto}',
3281
3281
  '.ph-sb-dir-seat{display:inline-flex;align-items:center;gap:2px}',
@@ -3291,7 +3291,11 @@ var GRAIN = 'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org
3291
3291
  '.ph-sb-dir-crumb:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ' +
3292
3292
  'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#3b6fe0));outline-offset:1px}',
3293
3293
  '.ph-sb-dir-crumb.is-current{color:var(--dsw-alias-label-primary,#e6e8ea)}',
3294
- '.ph-sb-dir-list{display:flex;flex-direction:column;gap:1px;min-height:0;overflow:auto}',
3294
+ // The list is the one child that gives way when the dialog hits its
3295
+ // height: a row of text shrunk to fit its box, which is how the target
3296
+ // line lost its lower half. Every other child keeps its height, and the
3297
+ // list scrolls instead.
3298
+ '.ph-sb-dir-list{display:flex;flex-direction:column;gap:1px;min-height:0;overflow:auto;flex:1 1 auto}',
3295
3299
  '.ph-sb-dir-row{display:flex;align-items:center;gap:4px;width:100%;box-sizing:border-box;height:28px;padding:0 4px;' +
3296
3300
  'border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;cursor:pointer;text-align:left;' +
3297
3301
  'font-family:inherit;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#e6e8ea)}',
@@ -3303,8 +3307,18 @@ var GRAIN = 'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org
3303
3307
  '.ph-sb-dir-row.is-hidden{opacity:.62}',
3304
3308
  '.ph-sb-dir-icon{flex:none;display:inline-flex;opacity:.7}',
3305
3309
  '.ph-sb-dir-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
3306
- '.ph-sb-dir-note{padding:4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#9aa4b2)}',
3307
- '.ph-sb-dir-newfold{display:flex;align-items:center;gap:6px}',
3310
+ '.ph-sb-dir-note{padding:4px;font-size:12px;line-height:18px;flex:none;' +
3311
+ 'color:var(--dsw-alias-label-secondary,#9aa4b2)}',
3312
+ // The target is a path, and the part of it that tells two folders apart is
3313
+ // the end: it wraps rather than ellipsizing the folder away, and it never
3314
+ // gives up height to the scrolling list below it (a clipped line is what a
3315
+ // shrunk flex item with `overflow:hidden` looks like).
3316
+ '.ph-sb-dir-target{flex:none;overflow-wrap:anywhere;' +
3317
+ 'color:var(--dsw-alias-label-primary,#e6e8ea)}',
3318
+ '.ph-sb-dir-newfold{display:flex;align-items:center;gap:6px;flex:none}',
3319
+ // The error line sits in the same column: it is the one thing the reader
3320
+ // must be able to read, so it keeps its height like the notes above it.
3321
+ '.ph-sb-dir .ph-sb-form-error{flex:none}',
3308
3322
  '.ph-sb-dir-newfold .ph-sb-field{flex:1 1 auto}',
3309
3323
  // The footer toggle from the shipped browser: plain text at control size,
3310
3324
  // no border, and the pressed state marked by a trailing check so the
@@ -4926,6 +4940,7 @@ var STYLES = [
4926
4940
  'sidebar.list.dirEmpty': '没有子文件夹',
4927
4941
  'sidebar.list.dirHiddenOnly': '没有可见的子文件夹,隐藏项已折叠',
4928
4942
  'sidebar.list.dirTruncated': '文件夹太多,只列出了前一部分',
4943
+ 'sidebar.list.dirTarget': '将在此创建工作区:{path}',
4929
4944
  'sidebar.list.dirFailed': '读不到这个目录:{reason}',
4930
4945
  'sidebar.list.apply': '确定',
4931
4946
  'sidebar.list.more': '更多',
@@ -5156,6 +5171,7 @@ var STYLES = [
5156
5171
  'sidebar.list.dirEmpty': 'No subfolders',
5157
5172
  'sidebar.list.dirHiddenOnly': 'No visible subfolders; hidden items are folded',
5158
5173
  'sidebar.list.dirTruncated': 'Too many folders; only the first ones are listed',
5174
+ 'sidebar.list.dirTarget': 'Creates the workspace in: {path}',
5159
5175
  'sidebar.list.dirFailed': 'Cannot read this folder: {reason}',
5160
5176
  'sidebar.list.apply': 'Apply',
5161
5177
  'sidebar.list.more': 'More',
@@ -5389,6 +5405,20 @@ var STYLES = [
5389
5405
  updatedAt: Number(row.updatedAt) || 0,
5390
5406
  }
5391
5407
  }
5408
+ /**
5409
+ * Read one workspace instant: the Host stamps Workspace rows with ISO-8601
5410
+ * strings, while a projection that was already reduced can carry epoch
5411
+ * milliseconds. Both are wanted by the list, which orders by them.
5412
+ * @param value - an ISO-8601 instant, epoch milliseconds, or anything else.
5413
+ * @returns epoch milliseconds, or 0 when it names no time.
5414
+ */
5415
+ function epochOf(value) {
5416
+ if (typeof value === 'number') return isFinite(value) && value > 0 ? value : 0
5417
+ var text = String(value === undefined || value === null ? '' : value)
5418
+ if (text === '') return 0
5419
+ var parsed = Date.parse(text)
5420
+ return isFinite(parsed) && parsed > 0 ? parsed : 0
5421
+ }
5392
5422
  /**
5393
5423
  * @param value - a workspace projection array.
5394
5424
  * @returns a clean projection array.
@@ -5403,6 +5433,10 @@ var STYLES = [
5403
5433
  title: String(item.title === undefined || item.title === null ? '' : item.title),
5404
5434
  path: String(item.path === undefined || item.path === null ? '' : item.path),
5405
5435
  sessions: rows.length || (Number(item.sessions) || 0),
5436
+ // When the Workspace itself was registered. A project with no Sessions
5437
+ // is not old — creating it is an event — so the list's recency order
5438
+ // counts this alongside its newest Session.
5439
+ createdAt: epochOf(item.createdAt),
5406
5440
  rows: rows,
5407
5441
  }
5408
5442
  }).filter(function (entry) { return entry.id !== '' })
@@ -5586,6 +5620,7 @@ var STYLES = [
5586
5620
  id: view.workspaceId,
5587
5621
  title: view.title,
5588
5622
  path: view.path,
5623
+ createdAt: epochOf(view.createdAt),
5589
5624
  rows: rows,
5590
5625
  }
5591
5626
  })
@@ -5645,7 +5680,7 @@ var STYLES = [
5645
5680
  var parts = [String(current || ''), Object.keys(subagents).sort().join(',')]
5646
5681
  for (var i = 0; i < items.length; i += 1) {
5647
5682
  var item = items[i]
5648
- parts.push(item.id, item.title, item.path, String(item.sessions))
5683
+ parts.push(item.id, item.title, item.path, String(item.sessions), String(item.createdAt))
5649
5684
  for (var j = 0; j < item.rows.length; j += 1) {
5650
5685
  var row = item.rows[j]
5651
5686
  parts.push(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-homepage",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "DeepSeek Harness plugin: a personal home page in the sidebar for a DSH instance. Review a plugin before installing it, and read sessions, projects, tool usage, device, storage, DSH cores, Skills and cost from one composable, registrable card wall.",
5
5
  "keywords": [
6
6
  "dsh-plugin",