dsh-nerd-mode 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/README.zh.md +10 -5
- package/client.js +491 -52
- package/lib/index.js +63 -0
- package/lib/study-sessions.js +283 -0
- package/package.json +70 -70
- package/skills/study/SKILL.md +1 -1
- package/skills/study-plus/SKILL.md +7 -7
- package/skills/study-plus/scripts/book-client.js +8 -10
- package/skills/study-plus/scripts/build-book.mjs +9 -3
package/client.js
CHANGED
|
@@ -1556,34 +1556,37 @@ window.__ModuleLoader__.load({
|
|
|
1556
1556
|
return model;
|
|
1557
1557
|
}
|
|
1558
1558
|
|
|
1559
|
-
// The
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1559
|
+
// The picker and Course name stay in view while only the lecture rows
|
|
1560
|
+
// scroll. The scroller owns the side padding, so its bar sits at the page
|
|
1561
|
+
// edge rather than inside the text.
|
|
1562
|
+
const LECTURES_CSS = `.lectures-page{box-sizing:border-box;height:100%;display:flex;flex-direction:column;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit)}
|
|
1563
|
+
.lectures-head{display:flex;align-items:center;gap:12px;padding:24px 32px 0}
|
|
1564
|
+
.lectures-picker{display:flex;align-items:center;gap:8px;flex:1;min-width:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
|
|
1565
|
+
.lectures-select{flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm,8px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit}
|
|
1566
|
+
.lectures-group{display:flex;flex-direction:column;flex:1;min-height:0}
|
|
1567
|
+
.lectures-group-head{display:flex;align-items:center;gap:8px;padding:20px 32px 8px}
|
|
1568
|
+
.lectures-group-title{margin:0;font-size:14px;line-height:22px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1569
|
+
.lectures-count{flex:none;font-size:13px;line-height:20px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
|
|
1570
|
+
.lectures-scroll{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:2px;padding:0 32px 32px}
|
|
1571
|
+
.lectures-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
|
|
1572
|
+
.lectures-item{margin:0 -8px}
|
|
1573
|
+
.lectures-row{position:relative;width:100%;box-sizing:border-box;display:grid;grid-template-columns:28px minmax(0,1fr);grid-template-areas:"tile title" "tile meta";column-gap:12px;row-gap:2px;align-items:start;padding:8px;border:none;border-radius:var(--dsw-radius-xl,20px);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:normal}
|
|
1574
|
+
.lectures-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
1575
|
+
.lectures-row:focus-visible{outline:none}
|
|
1576
|
+
.lectures-row:focus-visible::after{content:"";position:absolute;inset:0;border-radius:inherit;outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
|
1577
|
+
.lectures-tile{grid-area:tile;align-self:center;width:28px;height:28px;display:grid;place-items:center;border-radius:6px;background:var(--dsw-alias-fill-tsp-secondary);color:var(--dsw-alias-label-secondary);font:600 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
|
|
1578
|
+
.lectures-title{grid-area:title;font-size:14px;line-height:20px;font-weight:500;overflow-wrap:anywhere}
|
|
1579
|
+
.lectures-meta{grid-area:meta;display:flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
1580
|
+
.lectures-chip{display:inline-flex;align-items:center;height:18px;padding:0 7px;font-size:11px;line-height:17px;border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
|
|
1581
|
+
.lectures-fold{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;margin-top:12px;padding:0;border:none;background:none;font-size:14px;line-height:22px;font-weight:500;color:var(--dsw-alias-label-primary);cursor:pointer}
|
|
1582
|
+
.lectures-fold:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
|
1583
|
+
.lectures-chev{color:var(--dsw-alias-label-tertiary);transition:transform 120ms}
|
|
1584
|
+
.lectures-fold[aria-expanded="false"] .lectures-chev{transform:rotate(-90deg)}
|
|
1585
|
+
.lectures-status{margin:0;padding:8px 32px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
|
|
1586
|
+
.lectures-error{display:flex;align-items:center;gap:8px;margin:0;padding:8px 32px;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary)}
|
|
1587
|
+
.lectures-dot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor}
|
|
1588
|
+
.lectures-empty{margin:0;padding:48px 20px;text-align:center;font-size:14px;line-height:22px;color:var(--dsw-alias-label-tertiary)}
|
|
1589
|
+
.lectures-empty code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:1px 6px}`;
|
|
1587
1590
|
|
|
1588
1591
|
const LECTURE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
1589
1592
|
|
|
@@ -1618,41 +1621,74 @@ window.__ModuleLoader__.load({
|
|
|
1618
1621
|
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`;
|
|
1619
1622
|
}
|
|
1620
1623
|
|
|
1621
|
-
// The
|
|
1624
|
+
// The visible title is clipped, so the label repeats it in full.
|
|
1622
1625
|
function lectureRow(entry, onOpen) {
|
|
1623
1626
|
const mark = lectureMark(entry);
|
|
1624
|
-
|
|
1627
|
+
const label = mark === null
|
|
1628
|
+
? `${entry.number}, ${entry.title}`
|
|
1629
|
+
: `${entry.number}, ${entry.title}, ${mark}`;
|
|
1630
|
+
return React.createElement('li', { key: entry.number, className: 'lectures-item' },
|
|
1625
1631
|
React.createElement('button', {
|
|
1626
1632
|
type: 'button',
|
|
1627
|
-
className: 'lectures-
|
|
1633
|
+
className: 'lectures-row',
|
|
1634
|
+
'aria-label': label,
|
|
1628
1635
|
onClick: () => onOpen(entry),
|
|
1629
|
-
|
|
1630
|
-
|
|
1636
|
+
},
|
|
1637
|
+
React.createElement('span', { className: 'lectures-tile', 'aria-hidden': 'true' }, entry.number),
|
|
1638
|
+
React.createElement('span', { className: 'lectures-title' }, entry.title),
|
|
1639
|
+
mark === null
|
|
1640
|
+
? null
|
|
1641
|
+
: React.createElement('span', { className: 'lectures-meta' },
|
|
1642
|
+
entry.mark === 'dated'
|
|
1643
|
+
? mark
|
|
1644
|
+
: React.createElement('span', { className: 'lectures-chip' }, mark))));
|
|
1631
1645
|
}
|
|
1632
1646
|
|
|
1633
1647
|
function lectureTopic(group, open, onToggle, onOpen) {
|
|
1634
1648
|
const main = group.entries.filter((entry) => entry.mark !== 'archived');
|
|
1635
1649
|
const archived = group.entries.filter((entry) => entry.mark === 'archived');
|
|
1636
|
-
const
|
|
1650
|
+
const archiveId = `lectures-archive-${group.topic}`;
|
|
1651
|
+
const rows = [];
|
|
1637
1652
|
if (group.entries.length === 0) {
|
|
1638
|
-
|
|
1653
|
+
rows.push(React.createElement('p', { key: 'empty', className: 'lectures-empty' }, 'No lectures yet.'));
|
|
1639
1654
|
} else if (main.length > 0) {
|
|
1640
|
-
|
|
1655
|
+
rows.push(React.createElement('ul', { key: 'main', className: 'lectures-rows' }, ...main.map((entry) => lectureRow(entry, onOpen))));
|
|
1641
1656
|
}
|
|
1642
1657
|
if (archived.length > 0) {
|
|
1643
|
-
|
|
1658
|
+
rows.push(React.createElement('button', {
|
|
1644
1659
|
key: 'fold',
|
|
1645
1660
|
type: 'button',
|
|
1646
1661
|
className: 'lectures-fold',
|
|
1647
1662
|
'aria-expanded': open,
|
|
1663
|
+
// A closed fold has no list to control, so the reference would dangle.
|
|
1664
|
+
'aria-controls': open ? archiveId : undefined,
|
|
1665
|
+
'aria-label': `Archived lectures ${archived.length}`,
|
|
1648
1666
|
onClick: () => onToggle(group.topic),
|
|
1649
|
-
|
|
1650
|
-
|
|
1667
|
+
},
|
|
1668
|
+
React.createElement('span', null, 'Archived lectures'),
|
|
1669
|
+
React.createElement('span', { className: 'lectures-count' }, String(archived.length)),
|
|
1670
|
+
React.createElement('svg', {
|
|
1671
|
+
'aria-hidden': 'true',
|
|
1672
|
+
className: 'lectures-chev',
|
|
1673
|
+
fill: 'none',
|
|
1674
|
+
height: 12,
|
|
1675
|
+
stroke: 'currentColor',
|
|
1676
|
+
strokeLinecap: 'round',
|
|
1677
|
+
strokeLinejoin: 'round',
|
|
1678
|
+
strokeWidth: 1.6,
|
|
1679
|
+
viewBox: '0 0 16 16',
|
|
1680
|
+
width: 12,
|
|
1681
|
+
}, React.createElement('path', { d: 'M3.5 6 8 10.5 12.5 6' }))));
|
|
1651
1682
|
if (open) {
|
|
1652
|
-
|
|
1683
|
+
rows.push(React.createElement('ul', { key: 'archive', className: 'lectures-rows', id: archiveId }, ...archived.map((entry) => lectureRow(entry, onOpen))));
|
|
1653
1684
|
}
|
|
1654
1685
|
}
|
|
1655
|
-
|
|
1686
|
+
// The Course key remounts the section on a switch, so its scrollport starts at the top.
|
|
1687
|
+
return React.createElement('section', { key: group.topic, className: 'lectures-group' },
|
|
1688
|
+
React.createElement('div', { className: 'lectures-group-head' },
|
|
1689
|
+
React.createElement('h2', { className: 'lectures-group-title' }, group.topic),
|
|
1690
|
+
React.createElement('span', { className: 'lectures-count' }, String(main.length))),
|
|
1691
|
+
React.createElement('div', { className: 'lectures-scroll' }, ...rows));
|
|
1656
1692
|
}
|
|
1657
1693
|
|
|
1658
1694
|
function LecturesPage({ useTabInfo, sessionId, workspaceFiles }) {
|
|
@@ -1680,22 +1716,24 @@ window.__ModuleLoader__.load({
|
|
|
1680
1716
|
setSelected(course);
|
|
1681
1717
|
};
|
|
1682
1718
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
);
|
|
1719
|
+
// No placement option: the preview opens as a tab in this pane and the
|
|
1720
|
+
// host focuses it when that lecture is already open.
|
|
1721
|
+
const open = (entry) => tab.actions.openResource(sessionFileAddress(sessionId, entry.path));
|
|
1687
1722
|
|
|
1688
1723
|
let nodes;
|
|
1689
1724
|
if (content.status === 'loading') {
|
|
1690
|
-
nodes = [React.createElement('p', { role: 'status', 'aria-busy': true,
|
|
1725
|
+
nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, className: 'lectures-status' }, 'Loading the lectures…')];
|
|
1691
1726
|
} else if (content.error === null && !content.rootFound) {
|
|
1692
|
-
nodes = [React.createElement('p', {
|
|
1727
|
+
nodes = [React.createElement('p', { className: 'lectures-empty' },
|
|
1728
|
+
`No Course in ${TRACKER_ROOT}/. Run `,
|
|
1729
|
+
React.createElement('code', null, '/study-plus'),
|
|
1730
|
+
' to create one.')];
|
|
1693
1731
|
} else {
|
|
1694
1732
|
nodes = content.error === null
|
|
1695
1733
|
? []
|
|
1696
|
-
: [React.createElement('p', { role: 'alert',
|
|
1697
|
-
|
|
1698
|
-
|
|
1734
|
+
: [React.createElement('p', { role: 'alert', className: 'lectures-error' },
|
|
1735
|
+
React.createElement('span', { className: 'lectures-dot', 'aria-hidden': 'true' }),
|
|
1736
|
+
`The lecture library could not be read: ${content.error}.`)];
|
|
1699
1737
|
if (content.course !== null) {
|
|
1700
1738
|
nodes.push(lectureTopic(
|
|
1701
1739
|
{ topic: content.course, entries: content.entries },
|
|
@@ -1705,11 +1743,24 @@ window.__ModuleLoader__.load({
|
|
|
1705
1743
|
));
|
|
1706
1744
|
}
|
|
1707
1745
|
}
|
|
1746
|
+
|
|
1747
|
+
// One Course needs no picker: the page already shows that Course.
|
|
1748
|
+
const courses = content.courses ?? [];
|
|
1749
|
+
const picker = courses.length < 2 ? null : React.createElement('label', { className: 'lectures-picker' },
|
|
1750
|
+
React.createElement('span', null, 'Course'),
|
|
1751
|
+
React.createElement('select', {
|
|
1752
|
+
'aria-label': 'Course',
|
|
1753
|
+
className: 'lectures-select',
|
|
1754
|
+
value: content.course ?? selected,
|
|
1755
|
+
onChange: (event) => choose(event.target.value),
|
|
1756
|
+
}, ...courses.map((course) => React.createElement('option', { key: course, value: course }, course))));
|
|
1708
1757
|
return React.createElement('div', {
|
|
1709
1758
|
role: 'region',
|
|
1710
1759
|
'aria-label': LECTURES_LABEL,
|
|
1711
|
-
|
|
1712
|
-
}, React.createElement('style', null, LECTURES_CSS),
|
|
1760
|
+
className: 'lectures-page',
|
|
1761
|
+
}, React.createElement('style', null, LECTURES_CSS),
|
|
1762
|
+
React.createElement('header', { className: 'lectures-head' }, picker),
|
|
1763
|
+
...nodes);
|
|
1713
1764
|
}
|
|
1714
1765
|
|
|
1715
1766
|
// A page type names no resource pattern and no `multiple`, so the host
|
|
@@ -1727,6 +1778,365 @@ window.__ModuleLoader__.load({
|
|
|
1727
1778
|
}],
|
|
1728
1779
|
};
|
|
1729
1780
|
|
|
1781
|
+
// --- Study Sessions panel ------------------------------------------------
|
|
1782
|
+
|
|
1783
|
+
const STUDY_SESSIONS_ID = 'study-sessions';
|
|
1784
|
+
const STUDY_SESSIONS_ORDER = 30;
|
|
1785
|
+
const STUDY_SESSIONS_API = '/dsh-nerd-mode/api/study-sessions';
|
|
1786
|
+
const STUDY_SESSIONS_TITLE = { en: 'Study Sessions', zh: '学习会话' };
|
|
1787
|
+
const STUDY_QUICK = { en: 'Quick', zh: '快速' };
|
|
1788
|
+
const STUDY_IN_DEPTH = { en: 'In-depth', zh: '深入' };
|
|
1789
|
+
const STUDY_PAST = { en: 'Past', zh: '过去' };
|
|
1790
|
+
|
|
1791
|
+
// A host without the session package still gets the page and its empty line.
|
|
1792
|
+
const NO_SESSIONS_HOOK = () => null;
|
|
1793
|
+
|
|
1794
|
+
// A failed rescan or a remount must not blank the panel, so the last good scan outlives the component.
|
|
1795
|
+
let studySessionsCache = null;
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* Read one history chunk from the host route.
|
|
1799
|
+
*
|
|
1800
|
+
* Returns `{ phase, done, total, sessions, cursor }`. Throws an Error whose message names the failure:
|
|
1801
|
+
* a missing route (404/405 or a body that is not JSON) and any non-OK reply both count.
|
|
1802
|
+
*/
|
|
1803
|
+
async function readSessionChunk(cursor) {
|
|
1804
|
+
const response = await fetch(`${STUDY_SESSIONS_API}?cursor=${cursor}`);
|
|
1805
|
+
// The static fallback owns an unregistered path, so it answers with an empty body.
|
|
1806
|
+
if (response.status === 404 || response.status === 405) throw new Error('the host route is not available');
|
|
1807
|
+
// A rejected request carries plain text, so name the status before the body is parsed.
|
|
1808
|
+
if (response.status === 401 || response.status === 403) {
|
|
1809
|
+
throw new Error(`the host denied the request (HTTP ${response.status})`);
|
|
1810
|
+
}
|
|
1811
|
+
const body = await response.json().catch(() => null);
|
|
1812
|
+
if (body === null) throw new Error('the host returned no JSON');
|
|
1813
|
+
if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
|
|
1814
|
+
const sessions = body?.sessions;
|
|
1815
|
+
if (typeof sessions !== 'object' || sessions === null) throw new Error('the host returned no session history');
|
|
1816
|
+
return {
|
|
1817
|
+
phase: body.phase === 'ready' ? 'ready' : 'scanning',
|
|
1818
|
+
done: Number.isFinite(body.done) ? body.done : 0,
|
|
1819
|
+
total: Number.isFinite(body.total) ? body.total : 0,
|
|
1820
|
+
sessions,
|
|
1821
|
+
// The spec names no cursor field, so accept one or fall back to the processed count.
|
|
1822
|
+
cursor: Number.isFinite(body.cursor) ? body.cursor : Number.isFinite(body.done) ? body.done : cursor,
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/**
|
|
1827
|
+
* Scan the host history index from cursor 0, merging each chunk into `sessions`.
|
|
1828
|
+
*
|
|
1829
|
+
* Calls `onChunk` after every reply so the page can move its progress line. Returns the final
|
|
1830
|
+
* scan state. A host that repeats a cursor cannot make progress, so the loop stops there.
|
|
1831
|
+
*/
|
|
1832
|
+
async function scanSessionHistory(sessions, onChunk) {
|
|
1833
|
+
let state = { status: 'scanning', sessions, done: 0, total: 0, cursor: 0 };
|
|
1834
|
+
let previous = 0;
|
|
1835
|
+
for (;;) {
|
|
1836
|
+
const chunk = await readSessionChunk(state.cursor);
|
|
1837
|
+
state = {
|
|
1838
|
+
status: chunk.phase,
|
|
1839
|
+
// A ready reply carries the host's whole index, so it replaces stale cached ids.
|
|
1840
|
+
sessions: chunk.phase === 'ready' ? chunk.sessions : { ...state.sessions, ...chunk.sessions },
|
|
1841
|
+
done: chunk.done,
|
|
1842
|
+
total: chunk.total,
|
|
1843
|
+
cursor: chunk.cursor,
|
|
1844
|
+
};
|
|
1845
|
+
onChunk(state);
|
|
1846
|
+
if (state.status === 'ready' || chunk.cursor <= previous) return state;
|
|
1847
|
+
previous = chunk.cursor;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/** Return the last folder in a workspace path; Windows and POSIX separators both count. */
|
|
1852
|
+
function workspaceName(cwd) {
|
|
1853
|
+
if (typeof cwd !== 'string') return '';
|
|
1854
|
+
return cwd.split(/[\\/]+/).filter((part) => part !== '').pop() ?? '';
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
const RELATIVE_UNITS = [
|
|
1858
|
+
['year', 31536000000],
|
|
1859
|
+
['month', 2592000000],
|
|
1860
|
+
['day', 86400000],
|
|
1861
|
+
['hour', 3600000],
|
|
1862
|
+
['minute', 60000],
|
|
1863
|
+
['second', 1000],
|
|
1864
|
+
];
|
|
1865
|
+
|
|
1866
|
+
/** Return a locale-aware relative time such as `2 hours ago`; a missing time reads as an empty string. */
|
|
1867
|
+
function relativeTime(updatedAt, language) {
|
|
1868
|
+
if (!Number.isFinite(updatedAt)) return '';
|
|
1869
|
+
const elapsed = updatedAt - Date.now();
|
|
1870
|
+
const [unit, size] = RELATIVE_UNITS.find(([, step]) => Math.abs(elapsed) >= step) ?? RELATIVE_UNITS[5];
|
|
1871
|
+
return new Intl.RelativeTimeFormat(language, { numeric: 'auto' }).format(Math.round(elapsed / size), unit);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/** Join the live session list with the history index. A history id with no live row is dropped. */
|
|
1875
|
+
function sessionRows(snapshot, history) {
|
|
1876
|
+
const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
|
|
1877
|
+
const byId = snapshot?.byId ?? {};
|
|
1878
|
+
const rows = [];
|
|
1879
|
+
for (const id of ids) {
|
|
1880
|
+
const row = byId[id];
|
|
1881
|
+
if (row === undefined || !Object.hasOwn(history, id)) continue;
|
|
1882
|
+
rows.push({
|
|
1883
|
+
id,
|
|
1884
|
+
title: String(row.displayTitle ?? id),
|
|
1885
|
+
workspace: workspaceName(row.cwd),
|
|
1886
|
+
updatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : 0,
|
|
1887
|
+
active: row.projectionValues?.studyMode?.active === true,
|
|
1888
|
+
studyPlus: history[id]?.studyPlus === true,
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
return rows.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/** Split rows into Quick, In-depth, and Past; a missing live switch value counts as off. */
|
|
1895
|
+
function groupSessionRows(rows) {
|
|
1896
|
+
const groups = { quick: [], inDepth: [], past: [] };
|
|
1897
|
+
for (const row of rows) {
|
|
1898
|
+
if (!row.active) groups.past.push(row);
|
|
1899
|
+
else if (row.studyPlus) groups.inDepth.push(row);
|
|
1900
|
+
else groups.quick.push(row);
|
|
1901
|
+
}
|
|
1902
|
+
return groups;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// The tile is decoration; the group heading and the row label carry the meaning.
|
|
1906
|
+
function studyTile(kind) {
|
|
1907
|
+
const glyph = (children) => React.createElement('svg', {
|
|
1908
|
+
fill: 'none',
|
|
1909
|
+
height: 16,
|
|
1910
|
+
stroke: 'currentColor',
|
|
1911
|
+
strokeLinecap: 'round',
|
|
1912
|
+
strokeLinejoin: 'round',
|
|
1913
|
+
strokeWidth: 1.6,
|
|
1914
|
+
viewBox: '0 0 16 16',
|
|
1915
|
+
width: 16,
|
|
1916
|
+
}, ...children);
|
|
1917
|
+
if (kind === 'book') return glyph(BOOK.props.children);
|
|
1918
|
+
if (kind === 'quick') {
|
|
1919
|
+
return glyph([
|
|
1920
|
+
React.createElement('path', { d: 'M13.4 8.9a3.7 3.7 0 0 1-3.7 3.7H6.4L3.4 14.6v-2.5A3.7 3.7 0 0 1 1.8 9V7.5a3.7 3.7 0 0 1 3.7-3.7h4.2a3.7 3.7 0 0 1 3.7 3.7Z' }),
|
|
1921
|
+
React.createElement('path', { d: 'M5.2 7.3h5.6M5.2 9.3h3.4' }),
|
|
1922
|
+
]);
|
|
1923
|
+
}
|
|
1924
|
+
return glyph([
|
|
1925
|
+
React.createElement('circle', { cx: 8, cy: 8, r: 5.6 }),
|
|
1926
|
+
React.createElement('path', { d: 'M8 5.2V8l1.9 1.3' }),
|
|
1927
|
+
]);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// Inline because the client bundle cannot read assets/.
|
|
1931
|
+
function StudySessionsIcon({ size = 16, active = false }) {
|
|
1932
|
+
return React.createElement(
|
|
1933
|
+
'svg',
|
|
1934
|
+
{
|
|
1935
|
+
'aria-hidden': 'true',
|
|
1936
|
+
fill: 'none',
|
|
1937
|
+
height: size,
|
|
1938
|
+
stroke: 'currentColor',
|
|
1939
|
+
strokeLinecap: 'round',
|
|
1940
|
+
strokeLinejoin: 'round',
|
|
1941
|
+
strokeWidth: active ? 2 : 1.6,
|
|
1942
|
+
viewBox: '0 0 16 16',
|
|
1943
|
+
width: size,
|
|
1944
|
+
},
|
|
1945
|
+
React.createElement('path', { d: 'M2.5 4h11M2.5 8h11M2.5 12h7' }),
|
|
1946
|
+
React.createElement('circle', {
|
|
1947
|
+
cx: 12.5,
|
|
1948
|
+
cy: 12,
|
|
1949
|
+
fill: 'currentColor',
|
|
1950
|
+
opacity: active ? 1 : 0.45,
|
|
1951
|
+
r: 1.2,
|
|
1952
|
+
stroke: 'none',
|
|
1953
|
+
}),
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
function StudySessionsPage({ useSessions, locale, openSession }) {
|
|
1958
|
+
const t = useT(locale);
|
|
1959
|
+
const language = locale.getSnapshot()?.active ?? 'en';
|
|
1960
|
+
const [scan, setScan] = React.useState(() => studySessionsCache === null
|
|
1961
|
+
? { status: 'scanning', sessions: {}, done: 0, total: 0, cursor: 0 }
|
|
1962
|
+
: { ...studySessionsCache, status: 'scanning' });
|
|
1963
|
+
// Only a folded key reads true: In-depth and Quick start open, Past starts folded.
|
|
1964
|
+
const [folded, setFolded] = React.useState({ inDepth: false, quick: false, past: true });
|
|
1965
|
+
const snapshot = useSessions((state) => state);
|
|
1966
|
+
|
|
1967
|
+
React.useEffect(() => {
|
|
1968
|
+
let live = true;
|
|
1969
|
+
// A cached list seeds the scan, so rows stay while the host rescans.
|
|
1970
|
+
scanSessionHistory(studySessionsCache?.sessions ?? {}, (chunk) => {
|
|
1971
|
+
if (!live) return;
|
|
1972
|
+
studySessionsCache = chunk;
|
|
1973
|
+
setScan(chunk);
|
|
1974
|
+
}).catch((error) => {
|
|
1975
|
+
if (!live) return;
|
|
1976
|
+
setScan((previous) => ({
|
|
1977
|
+
...previous,
|
|
1978
|
+
status: 'failed',
|
|
1979
|
+
reason: error?.message ?? 'the host did not answer',
|
|
1980
|
+
}));
|
|
1981
|
+
});
|
|
1982
|
+
return () => { live = false; };
|
|
1983
|
+
}, []);
|
|
1984
|
+
|
|
1985
|
+
const grouped = groupSessionRows(sessionRows(snapshot, scan.sessions));
|
|
1986
|
+
|
|
1987
|
+
// The row is one target, so its label names the title, workspace, time, and mode.
|
|
1988
|
+
const rowNode = (row, past) => {
|
|
1989
|
+
const time = relativeTime(row.updatedAt, language);
|
|
1990
|
+
const chip = past ? t(row.studyPlus ? STUDY_IN_DEPTH : STUDY_QUICK) : null;
|
|
1991
|
+
const label = [row.title, row.workspace, time, chip]
|
|
1992
|
+
.filter((part) => part !== '' && part !== null)
|
|
1993
|
+
.join(', ');
|
|
1994
|
+
return React.createElement('li', { className: 'study-ss-item', key: row.id },
|
|
1995
|
+
React.createElement('button', {
|
|
1996
|
+
type: 'button',
|
|
1997
|
+
className: 'study-ss-row',
|
|
1998
|
+
'aria-label': label,
|
|
1999
|
+
onClick: () => openSession(row.id),
|
|
2000
|
+
},
|
|
2001
|
+
React.createElement('span', { className: 'study-ss-tile', 'aria-hidden': 'true' },
|
|
2002
|
+
studyTile(past ? 'past' : row.studyPlus ? 'book' : 'quick')),
|
|
2003
|
+
React.createElement('span', { className: 'study-ss-title' }, row.title),
|
|
2004
|
+
React.createElement('span', { className: 'study-ss-meta' },
|
|
2005
|
+
row.workspace === '' ? null : React.createElement('span', { className: 'study-ss-workspace' }, row.workspace),
|
|
2006
|
+
chip === null ? null : React.createElement('span', {
|
|
2007
|
+
className: 'study-ss-chip',
|
|
2008
|
+
'data-study-chip': '',
|
|
2009
|
+
}, chip)),
|
|
2010
|
+
time === '' ? null : React.createElement('span', { className: 'study-ss-time' }, time)));
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
const nodes = [];
|
|
2014
|
+
if (scan.status === 'scanning') {
|
|
2015
|
+
const percent = scan.total > 0 ? Math.round((scan.done / scan.total) * 100) : 0;
|
|
2016
|
+
nodes.push(React.createElement('div', { key: 'progress', className: 'study-ss-scan' },
|
|
2017
|
+
React.createElement('p', {
|
|
2018
|
+
role: 'status',
|
|
2019
|
+
'aria-busy': true,
|
|
2020
|
+
className: 'study-ss-status',
|
|
2021
|
+
}, t({
|
|
2022
|
+
en: `Scanning session history… ${scan.done} of ${scan.total}`,
|
|
2023
|
+
zh: `正在扫描会话历史… ${scan.done} / ${scan.total}`,
|
|
2024
|
+
})),
|
|
2025
|
+
React.createElement('div', { className: 'study-ss-track', 'aria-hidden': 'true' },
|
|
2026
|
+
React.createElement('div', { className: 'study-ss-fill', style: { width: `${percent}%` } }))));
|
|
2027
|
+
}
|
|
2028
|
+
if (scan.status === 'failed') {
|
|
2029
|
+
nodes.push(React.createElement('p', {
|
|
2030
|
+
key: 'failure',
|
|
2031
|
+
role: 'alert',
|
|
2032
|
+
className: 'study-ss-error',
|
|
2033
|
+
},
|
|
2034
|
+
React.createElement('span', { className: 'study-ss-dot', 'aria-hidden': 'true' }),
|
|
2035
|
+
t({
|
|
2036
|
+
en: `The session index could not be read: ${scan.reason}`,
|
|
2037
|
+
zh: `无法读取会话索引:${scan.reason}`,
|
|
2038
|
+
})));
|
|
2039
|
+
}
|
|
2040
|
+
if (scan.status === 'ready' && Object.keys(scan.sessions).length === 0) {
|
|
2041
|
+
nodes.push(React.createElement('p', { key: 'empty', className: 'study-ss-empty' },
|
|
2042
|
+
t({
|
|
2043
|
+
en: 'No study session yet. Turn on Nerd Mode from the composer bar, or run',
|
|
2044
|
+
zh: '还没有学习会话。在输入栏打开 Nerd Mode,或运行',
|
|
2045
|
+
}),
|
|
2046
|
+
' ',
|
|
2047
|
+
React.createElement('code', null, '/nerd-mode on'),
|
|
2048
|
+
t({ en: '.', zh: '。' })));
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
const group = (key, label, list, past) => {
|
|
2052
|
+
if (list.length === 0) return null;
|
|
2053
|
+
const open = folded[key] === false;
|
|
2054
|
+
const listId = `study-ss-rows-${key}`;
|
|
2055
|
+
return React.createElement('section', { key, className: 'study-ss-group' },
|
|
2056
|
+
React.createElement('h2', { className: 'study-ss-group-title' },
|
|
2057
|
+
React.createElement('button', {
|
|
2058
|
+
type: 'button',
|
|
2059
|
+
className: 'study-ss-fold',
|
|
2060
|
+
'aria-expanded': open,
|
|
2061
|
+
// A folded group has no list to control, so the reference would dangle.
|
|
2062
|
+
'aria-controls': open ? listId : undefined,
|
|
2063
|
+
'aria-label': `${t(label)} ${list.length}`,
|
|
2064
|
+
onClick: () => setFolded((previous) => ({ ...previous, [key]: !previous[key] })),
|
|
2065
|
+
},
|
|
2066
|
+
React.createElement('span', null, t(label)),
|
|
2067
|
+
React.createElement('span', { className: 'study-ss-count' }, String(list.length)),
|
|
2068
|
+
React.createElement('svg', {
|
|
2069
|
+
'aria-hidden': 'true',
|
|
2070
|
+
className: 'study-ss-chev',
|
|
2071
|
+
fill: 'none',
|
|
2072
|
+
height: 12,
|
|
2073
|
+
stroke: 'currentColor',
|
|
2074
|
+
strokeLinecap: 'round',
|
|
2075
|
+
strokeLinejoin: 'round',
|
|
2076
|
+
strokeWidth: 1.6,
|
|
2077
|
+
viewBox: '0 0 16 16',
|
|
2078
|
+
width: 12,
|
|
2079
|
+
}, React.createElement('path', { d: 'M3.5 6 8 10.5 12.5 6' })))),
|
|
2080
|
+
open
|
|
2081
|
+
? React.createElement('ul', { className: 'study-ss-rows', id: listId },
|
|
2082
|
+
...list.map((row) => rowNode(row, past)))
|
|
2083
|
+
: null);
|
|
2084
|
+
};
|
|
2085
|
+
nodes.push(group('inDepth', STUDY_IN_DEPTH, grouped.inDepth, false));
|
|
2086
|
+
nodes.push(group('quick', STUDY_QUICK, grouped.quick, false));
|
|
2087
|
+
nodes.push(group('past', STUDY_PAST, grouped.past, true));
|
|
2088
|
+
|
|
2089
|
+
return React.createElement('div', {
|
|
2090
|
+
role: 'region',
|
|
2091
|
+
'aria-label': t(STUDY_SESSIONS_TITLE),
|
|
2092
|
+
className: 'study-ss-page',
|
|
2093
|
+
}, React.createElement('style', null, STUDY_SESSIONS_CSS),
|
|
2094
|
+
React.createElement('header', { className: 'study-ss-head', 'data-window-drag': '' },
|
|
2095
|
+
React.createElement('h1', { className: 'study-ss-title' }, t(STUDY_SESSIONS_TITLE)),
|
|
2096
|
+
React.createElement('p', { className: 'study-ss-intro' }, t({
|
|
2097
|
+
en: 'Every conversation where Nerd Mode was on.',
|
|
2098
|
+
zh: '开启过 Nerd Mode 的会话都在这里。',
|
|
2099
|
+
}))),
|
|
2100
|
+
...nodes);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// The page shell, rows, and fold live in CSS so a container query can fold
|
|
2104
|
+
// the time under the title on narrow panes. Tokens come from the host theme.
|
|
2105
|
+
const STUDY_SESSIONS_CSS = `.study-ss-page{box-sizing:border-box;height:100%;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:32px;padding:0 clamp(24px,4vw,48px) 48px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit);container-type:inline-size}
|
|
2106
|
+
.study-ss-page>*{width:100%;max-width:960px}
|
|
2107
|
+
.study-ss-head{display:flex;flex-direction:column;padding-top:28px}
|
|
2108
|
+
[data-platform="darwin"] .study-ss-head{padding-top:calc(28px + var(--dsh-frame-top-clearance,0px))}
|
|
2109
|
+
.study-ss-title{font-size:20px;line-height:28px;font-weight:500;margin:0}
|
|
2110
|
+
.study-ss-intro{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:4px 0 0}
|
|
2111
|
+
.study-ss-group{display:flex;flex-direction:column;gap:8px}
|
|
2112
|
+
.study-ss-group-title{font-size:14px;line-height:22px;font-weight:500;margin:0}
|
|
2113
|
+
.study-ss-fold{display:inline-flex;align-items:center;gap:8px;padding:0;background:none;border:none;font:inherit;color:inherit;cursor:pointer}
|
|
2114
|
+
.study-ss-fold:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
|
2115
|
+
.study-ss-count{font-size:13px;line-height:20px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
|
|
2116
|
+
.study-ss-chev{color:var(--dsw-alias-label-tertiary);transition:transform 120ms}
|
|
2117
|
+
.study-ss-fold[aria-expanded="false"] .study-ss-chev{transform:rotate(-90deg)}
|
|
2118
|
+
.study-ss-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
|
|
2119
|
+
.study-ss-item{margin:0 -8px}
|
|
2120
|
+
.study-ss-row{position:relative;width:100%;box-sizing:border-box;display:grid;grid-template-columns:32px minmax(0,1fr) auto;grid-template-areas:"tile title time" "tile meta time";column-gap:12px;row-gap:2px;align-items:start;padding:8px;border:none;border-radius:var(--dsw-radius-xl,20px);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:normal}
|
|
2121
|
+
.study-ss-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
2122
|
+
.study-ss-row:focus-visible{outline:none}
|
|
2123
|
+
.study-ss-row:focus-visible::after{content:"";position:absolute;inset:0;border-radius:inherit;outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
|
2124
|
+
.study-ss-tile{grid-area:tile;align-self:center;width:32px;height:32px;display:grid;place-items:center;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
|
|
2125
|
+
.study-ss-title{grid-area:title;font-size:14px;line-height:20px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2126
|
+
.study-ss-meta{grid-area:meta;display:flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:18px;color:var(--dsw-alias-label-tertiary);flex-wrap:wrap}
|
|
2127
|
+
.study-ss-workspace{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2128
|
+
.study-ss-time{grid-area:time;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;margin-top:2px}
|
|
2129
|
+
.study-ss-chip{display:inline-flex;align-items:center;height:18px;padding:0 7px;font-size:11px;line-height:17px;border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
|
|
2130
|
+
@container (max-width:520px){.study-ss-row{grid-template-areas:"tile title title" "tile meta time"}.study-ss-time{margin-top:0}}
|
|
2131
|
+
.study-ss-scan{display:flex;flex-direction:column;gap:10px}
|
|
2132
|
+
.study-ss-status{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:0}
|
|
2133
|
+
.study-ss-track{height:2px;background:var(--dsw-alias-border-l3);border-radius:999px;overflow:hidden}
|
|
2134
|
+
.study-ss-fill{height:100%;background:var(--dsw-alias-state-business-primary)}
|
|
2135
|
+
.study-ss-error{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary);margin:0}
|
|
2136
|
+
.study-ss-dot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor}
|
|
2137
|
+
.study-ss-empty{text-align:center;padding:48px 20px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-tertiary);margin:0}
|
|
2138
|
+
.study-ss-empty code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:1px 6px}`;
|
|
2139
|
+
|
|
1730
2140
|
return {
|
|
1731
2141
|
inject: ['slots', 'remote', 'remote.commands', 'remote.workspaceFiles', 'configForms'],
|
|
1732
2142
|
apply(ctx) {
|
|
@@ -1754,6 +2164,35 @@ window.__ModuleLoader__.load({
|
|
|
1754
2164
|
locale: localeCtx.locale,
|
|
1755
2165
|
})),
|
|
1756
2166
|
);
|
|
2167
|
+
// One id addresses both seats: the sidebar row selects the keyed main page.
|
|
2168
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
2169
|
+
ctx.slots.register({
|
|
2170
|
+
name: 'sidebar.panellist',
|
|
2171
|
+
id: STUDY_SESSIONS_ID,
|
|
2172
|
+
order: STUDY_SESSIONS_ORDER,
|
|
2173
|
+
// The label owns its translator: the slot's `t` prop belongs to the icon.
|
|
2174
|
+
label: () => localeCtx.locale.resolveText(STUDY_SESSIONS_TITLE),
|
|
2175
|
+
}, StudySessionsIcon),
|
|
2176
|
+
);
|
|
2177
|
+
ctx.slots.inject('main', () =>
|
|
2178
|
+
ctx.slots.register({
|
|
2179
|
+
name: 'main',
|
|
2180
|
+
key: STUDY_SESSIONS_ID,
|
|
2181
|
+
inject: () => ({
|
|
2182
|
+
locale: localeCtx.locale,
|
|
2183
|
+
openSession: (id) => {
|
|
2184
|
+
try {
|
|
2185
|
+
ctx.get('uiWorkspace')?.openSession(id);
|
|
2186
|
+
} catch {
|
|
2187
|
+
// A stale row must not take the panel down.
|
|
2188
|
+
}
|
|
2189
|
+
},
|
|
2190
|
+
}),
|
|
2191
|
+
}, (props) => React.createElement(StudySessionsPage, {
|
|
2192
|
+
...props,
|
|
2193
|
+
useSessions: typeof props.useSessions === 'function' ? props.useSessions : NO_SESSIONS_HOOK,
|
|
2194
|
+
})),
|
|
2195
|
+
);
|
|
1757
2196
|
});
|
|
1758
2197
|
ctx.slots.inject(VIEW_SLOT, () =>
|
|
1759
2198
|
ctx.slots.register({
|