dsh-remote-plugin 0.6.6 → 0.6.7

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.
@@ -2,7 +2,7 @@
2
2
  html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; background: var(--dsr-bg); color: var(--dsr-text); }
3
3
  .hidden { display: none !important; }
4
4
  .ds-app { position: fixed; inset: 0; display: flex; overflow: hidden; }
5
- .ds-sidebar { width: 280px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--dsr-line); background: var(--dsr-panel); }
5
+ .ds-sidebar { width: 280px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--dsr-line); background: var(--dsr-panel); overflow: hidden; }
6
6
  .ds-brand { display: flex; align-items: center; gap: 8px; padding: 14px 14px 10px; font-weight: 700; font-size: 15px; }
7
7
  .ds-logo { color: var(--dsr-accent-strong); }
8
8
  .ds-brand-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -41,7 +41,8 @@ a.ds-btn { text-decoration: none; }
41
41
  .ds-session-meta { display: flex; gap: 7px; font-size: 11px; color: var(--dsr-muted); align-items: center; }
42
42
  .ds-session-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dsr-line); }
43
43
  .ds-session-dot.running { background: var(--dsr-warning); }
44
- .ds-sidebar-foot { border-top: 1px solid var(--dsr-line); padding: 8px; display: flex; flex-direction: column; gap: 3px; }
44
+ .ds-sidebar-foot { border-top: 1px solid var(--dsr-line); padding: 8px; display: flex; flex-direction: column; gap: 3px; max-height: 58vh; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--dsr-line) transparent; }
45
+ .ds-sidebar-foot > * { flex-shrink: 0; }
45
46
  .ds-nav-item { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; border: none; background: transparent; color: var(--dsr-text); font: inherit; font-size: 13px; padding: 9px 10px; border-radius: 9px; cursor: pointer; }
46
47
  .ds-nav-item:hover { background: var(--dsr-bg-2); }
47
48
  .ds-nav-item.active { background: var(--dsr-accent-soft); color: var(--dsr-accent-strong); }
@@ -80,6 +81,48 @@ a.ds-btn { text-decoration: none; }
80
81
  .ds-feedback-desc { font-size: 11px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
81
82
  @keyframes ds-feedback-in { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: translateY(0) } }
82
83
 
84
+ /* ---------- 工作台 ---------- */
85
+ .ds-workbench { border: 1px solid var(--dsr-line); border-radius: 12px; background: var(--dsr-bg-2); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
86
+ .ds-wb-badge { font-size: 11.5px; font-weight: 700; color: var(--dsr-accent-strong); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
87
+ .ds-wb-unbound, .ds-wb-bound { display: flex; flex-direction: column; gap: 6px; }
88
+ .ds-wb-bind-btn { justify-content: center; width: 100%; }
89
+ .ds-wb-api-hint { font-size: 10.5px; color: var(--dsr-warning); line-height: 1.4; }
90
+ .ds-wb-head { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; border: none; background: transparent; color: var(--dsr-text); font: inherit; font-size: 12.5px; font-weight: 700; padding: 2px; cursor: pointer; }
91
+ .ds-wb-caret { flex: none; color: var(--dsr-muted); font-size: 10px; width: 12px; text-align: center; }
92
+ .ds-wb-head-text { flex: 1; min-width: 0; }
93
+ .ds-wb-actions { display: flex; gap: 6px; }
94
+ .ds-wb-actions .ds-mini-btn { flex: 1; }
95
+ .ds-wb-panel { border-top: 1px solid var(--dsr-divider); padding-top: 6px; display: flex; flex-direction: column; gap: 5px; max-height: 42vh; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--dsr-line) transparent; }
96
+ .ds-wb-panel > * { flex-shrink: 0; }
97
+ .ds-wb-panel-title { font-size: 10.5px; font-weight: 700; color: var(--dsr-muted); letter-spacing: .6px; text-transform: uppercase; }
98
+ .ds-wb-project { border: 1px solid var(--dsr-line); border-radius: 9px; background: var(--dsr-panel); overflow: hidden; }
99
+ .ds-wb-project-head { width: 100%; display: flex; align-items: center; gap: 6px; border: none; background: transparent; color: var(--dsr-text); font: inherit; font-size: 12.5px; padding: 6px 8px; cursor: pointer; text-align: left; }
100
+ .ds-wb-project-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
101
+ .ds-wb-project-count { flex: none; font-size: 10.5px; color: var(--dsr-muted); background: var(--dsr-bg-2); border-radius: 999px; padding: 0 7px; line-height: 16px; }
102
+ .ds-wb-project-body { display: flex; flex-direction: column; gap: 3px; padding: 2px 6px 7px; }
103
+ .ds-wb-new-session { align-self: flex-start; margin: 2px 0 3px; }
104
+ .ds-wb-session { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; border: none; background: transparent; color: var(--dsr-text); font: inherit; font-size: 12px; padding: 5px 7px; border-radius: 7px; cursor: pointer; }
105
+ .ds-wb-session.current { background: var(--dsr-accent-soft); color: var(--dsr-accent-strong); }
106
+ .ds-wb-session-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsr-line); }
107
+ .ds-wb-session-dot.running { background: var(--dsr-warning); }
108
+ .ds-wb-session-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
109
+ .ds-wb-session-empty, .ds-wb-empty { font-size: 11px; color: var(--dsr-muted); padding: 5px 7px; line-height: 1.5; }
110
+ .ds-wb-unbind-panel { align-self: flex-end; }
111
+ .ds-archived-toggle { width: 100%; border: none; background: transparent; color: var(--dsr-muted); font: inherit; font-size: 11px; letter-spacing: .4px; padding: 7px 4px; cursor: pointer; text-align: center; }
112
+
113
+ /* ---------- 工作台绑定弹窗 ---------- */
114
+ .ds-wb-modal-card { width: min(620px, 100%); }
115
+ .ds-wb-modal-desc { font-size: 12.5px; color: var(--dsr-muted); line-height: 1.5; }
116
+ .ds-wb-fsbar, .ds-wb-manual { display: flex; align-items: center; gap: 8px; }
117
+ .ds-wb-fs-path { flex: 1; min-width: 0; font-size: 12px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
118
+ .ds-wb-fs-list { border: 1px solid var(--dsr-line); border-radius: 12px; background: var(--dsr-panel); max-height: 42vh; overflow-y: auto; }
119
+ .ds-wb-fs-row { display: flex; align-items: center; gap: 9px; padding: 8px 11px; border-bottom: 1px solid var(--dsr-divider); font-size: 13px; cursor: pointer; }
120
+ .ds-wb-fs-row:last-child { border-bottom: none; }
121
+ .ds-wb-fs-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
122
+ .ds-wb-select { flex: none; }
123
+ .ds-wb-manual input { flex: 1; min-width: 0; background: var(--dsr-panel); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 8px 10px; font: inherit; font-size: 13px; outline: none; }
124
+ .ds-wb-manual input:focus { border-color: var(--dsr-accent-line); }
125
+
83
126
  /* 写反馈弹层 */
84
127
  .ds-fb-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
85
128
  .ds-fb-chip {
@@ -30,6 +30,24 @@
30
30
  </div>
31
31
  <div class="ds-session-list" id="session-list"></div>
32
32
  <div class="ds-sidebar-foot">
33
+ <div id="workbench-box" class="ds-workbench">
34
+ <div id="wb-unbound" class="ds-wb-unbound">
35
+ <div class="ds-wb-badge" data-i18n="wb.unbound">DSH Remote(未绑定)</div>
36
+ <button id="btn-wb-bind" class="ds-btn ds-wb-bind-btn" data-i18n="wb.bind">绑定工作台</button>
37
+ <div id="wb-api-hint" class="ds-wb-api-hint hidden" data-i18n="wb.apiMissing">工作台接口不可用(需更新网关)</div>
38
+ </div>
39
+ <div id="wb-bound" class="ds-wb-bound hidden">
40
+ <button id="wb-head" class="ds-wb-head" type="button" aria-expanded="false">
41
+ <span id="wb-caret" class="ds-wb-caret" aria-hidden="true">▸</span>
42
+ <span id="wb-head-text" class="ds-wb-head-text"></span>
43
+ </button>
44
+ <div class="ds-wb-actions">
45
+ <button id="btn-wb-path" class="ds-mini-btn" data-i18n="wb.viewPath">已绑定 workspace</button>
46
+ <button id="btn-wb-unbind" class="ds-mini-btn" data-i18n="wb.unbind">解绑</button>
47
+ </div>
48
+ <div id="wb-panel" class="ds-wb-panel hidden"></div>
49
+ </div>
50
+ </div>
33
51
  <button class="ds-nav-item" data-view="view-files"><span class="ds-nav-ico">⇅</span><span data-i18n="ds.files">文件传输</span></button>
34
52
  <button class="ds-nav-item" data-view="view-settings"><span class="ds-nav-ico">⚙</span><span data-i18n="ds.settings">设置</span></button>
35
53
  <div class="ds-feedback">
@@ -307,6 +325,28 @@
307
325
  </div>
308
326
  </div>
309
327
 
328
+ <div id="modal-workbench" class="ds-modal hidden">
329
+ <div class="ds-modal-card ds-wb-modal-card">
330
+ <div class="ds-modal-title" data-i18n="wb.modalTitle">绑定工作台</div>
331
+ <div class="ds-modal-body">
332
+ <div class="ds-wb-modal-desc" data-i18n="wb.modalDesc">选择工作台根目录,其下每个子文件夹会自动成为项目工作区。</div>
333
+ <div class="ds-wb-fsbar">
334
+ <button id="wb-fs-up" class="ds-btn" data-i18n="wb.up">上级</button>
335
+ <span id="wb-fs-path" class="ds-wb-fs-path">~</span>
336
+ <button id="wb-fs-home" class="ds-btn" data-i18n="wb.home">根目录</button>
337
+ </div>
338
+ <div id="wb-fs-list" class="ds-wb-fs-list"></div>
339
+ <div class="ds-wb-manual">
340
+ <input id="wb-path-input" type="text" autocomplete="off" data-i18n-placeholder="wb.pathPlaceholder" placeholder="或手动输入绝对路径…">
341
+ <button id="btn-wb-bind-manual" class="ds-btn primary" data-i18n="wb.bind">绑定</button>
342
+ </div>
343
+ </div>
344
+ <div class="ds-modal-actions">
345
+ <button id="btn-wb-modal-close" class="ds-btn" data-i18n="ds.cancel">取消</button>
346
+ </div>
347
+ </div>
348
+ </div>
349
+
310
350
  <!-- 赞赏支持模态 -->
311
351
  <div id="modal-donate" class="ds-modal hidden">
312
352
  <div class="ds-modal-card">
@@ -62,6 +62,8 @@ const state = {
62
62
  pollSeq: { mux: 0, host: 0 },
63
63
  fs: { path: null, initial: null, loaded: false },
64
64
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
65
+ wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
66
+ archivedIds: [],
65
67
  view: 'sessions'
66
68
  }
67
69
  const streams = {}
@@ -1102,6 +1104,7 @@ async function refreshSessions() {
1102
1104
  state.sessions = v.items || []
1103
1105
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1104
1106
  renderSessions()
1107
+ scheduleWorkbenchRefresh()
1105
1108
  }
1106
1109
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1107
1110
  function sessionWorkspaceLabel(s) {
@@ -1128,31 +1131,49 @@ function sortedSessions() {
1128
1131
  return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1129
1132
  }
1130
1133
  function renderSessions() {
1131
- const items = sortedSessions()
1132
- let lastWorkspace = null
1133
- const rows = []
1134
- for (const s of items) {
1135
- const workspace = sessionWorkspaceLabel(s)
1136
- const workspaceName = workspaceDisplayName(workspace)
1137
- if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1138
- rows.push(`<div class="ds-session-group" title="${esc(workspace)}"><span class="ds-session-group-icon" aria-hidden="true">⌂</span><span class="ds-session-group-name">${esc(workspaceName)}</span></div>`)
1139
- lastWorkspace = workspace
1134
+ const allItems = sortedSessions()
1135
+ const wbIds = new Set()
1136
+ if (state.wb.bound && state.wb.projects) for (const w of state.wb.projects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1137
+ const root = state.wb.bound ? state.wb.path : ''
1138
+ const visible = allItems.filter(s => !(state.wb.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
1139
+ const archivedSet = new Set(state.archivedIds || [])
1140
+ const archived = visible.filter(s => archivedSet.has(s.sessionId))
1141
+ const main = visible.filter(s => !archivedSet.has(s.sessionId))
1142
+ const showArchived = LS.get('dsShowArchivedV1', '0') === '1'
1143
+ const renderItems = (items) => {
1144
+ let lastWorkspace = null
1145
+ const rows = []
1146
+ for (const s of items) {
1147
+ const workspace = sessionWorkspaceLabel(s)
1148
+ const workspaceName = workspaceDisplayName(workspace)
1149
+ if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1150
+ rows.push(`<div class="ds-session-group" title="${esc(workspace)}"><span class="ds-session-group-icon" aria-hidden="true">⌂</span><span class="ds-session-group-name">${esc(workspaceName)}</span></div>`)
1151
+ lastWorkspace = workspace
1152
+ }
1153
+ const title = titleOf(s)
1154
+ rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1155
+ <span class="ds-session-title">${esc(title)}</span>
1156
+ <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1157
+ <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1158
+ </button>`)
1140
1159
  }
1141
- const title = titleOf(s)
1142
- rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1143
- <span class="ds-session-title">${esc(title)}</span>
1144
- <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1145
- <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1146
- </button>`)
1147
- }
1148
- const html = rows.join('') || `<div class="ds-empty">${t('ds.sessionsEmpty')}</div>`
1160
+ return rows.join('')
1161
+ }
1162
+ const divider = archived.length ? `<button class="ds-archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1163
+ const hiddenByWorkbench = allItems.length - visible.length
1164
+ const html = renderItems(main) + divider + (showArchived ? renderItems(archived) : '') || `<div class="ds-empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('ds.sessionsEmpty'))}</div>`
1149
1165
  $('session-list').innerHTML = html
1150
1166
  $('mobile-session-list').innerHTML = html
1151
1167
  $('session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1152
1168
  $('mobile-session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1153
1169
  const sort = $('session-sort')
1154
1170
  if (sort) sort.value = state.sessionSort
1171
+ document.querySelectorAll('[data-archived-toggle]').forEach(b => b.addEventListener('click', () => {
1172
+ LS.set('dsShowArchivedV1', LS.get('dsShowArchivedV1', '0') === '1' ? '0' : '1')
1173
+ renderSessions()
1174
+ }))
1155
1175
  document.querySelectorAll('[data-id]').forEach(b => b.addEventListener('click', () => openSession(b.dataset.id)))
1176
+ renderWorkbench()
1156
1177
  }
1157
1178
 
1158
1179
  async function openSession(id) {
@@ -1592,6 +1613,228 @@ function fsUp() {
1592
1613
  }
1593
1614
  }
1594
1615
 
1616
+ /* ---------------- 工作台绑定 / 项目会话 ---------------- */
1617
+ function wbPathKey(p) {
1618
+ let value = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
1619
+ if (value.length > 1) value = value.replace(/\/+$/, '')
1620
+ const windows = /^[A-Za-z]:\//.test(value) || /Windows/i.test(navigator.platform || navigator.userAgent || '')
1621
+ return windows ? value.toLowerCase() : value
1622
+ }
1623
+ function wbBaseName(p) {
1624
+ const value = String(p || '').replace(/[\\/]+$/, '')
1625
+ return value.split(/[\\/]/).pop() || value
1626
+ }
1627
+ function wbStrictInside(pathValue, rootValue) {
1628
+ const pathKey = wbPathKey(pathValue)
1629
+ const rootKey = wbPathKey(rootValue)
1630
+ if (!pathKey || !rootKey || pathKey === rootKey) return false
1631
+ return pathKey.startsWith(rootKey.endsWith('/') ? rootKey : rootKey + '/')
1632
+ }
1633
+ function wbJoin(root, name) {
1634
+ const raw = String(root || '')
1635
+ const separator = raw.includes('\\') ? '\\' : '/'
1636
+ return raw.replace(/[\\/]+$/, '') + separator + String(name || '')
1637
+ }
1638
+ function wbFsParent(p) {
1639
+ if (!p) return null
1640
+ const raw = String(p)
1641
+ const separator = raw.includes('\\') ? '\\' : '/'
1642
+ const index = raw.lastIndexOf(separator)
1643
+ if (index <= 0 || /^[A-Za-z]:$/.test(raw.slice(0, index))) return null
1644
+ return raw.slice(0, index)
1645
+ }
1646
+ async function wbGateway(method, pathname, body) {
1647
+ const options = { method, headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' } }
1648
+ if (body !== undefined) {
1649
+ options.headers['content-type'] = 'application/json'
1650
+ options.body = JSON.stringify(body)
1651
+ }
1652
+ const res = await fetch(apiUrl(pathname), options)
1653
+ if (res.status === 401) throw new Error('AUTH')
1654
+ const data = await res.json().catch(() => ({}))
1655
+ if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status))
1656
+ return data
1657
+ }
1658
+ async function refreshWorkbench({ silent = false } = {}) {
1659
+ if (!state.token) { renderWorkbench(); return }
1660
+ let wb = null
1661
+ try {
1662
+ wb = await wbGateway('GET', '/workbench')
1663
+ state.wb.apiMissing = false
1664
+ } catch (e) {
1665
+ if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return }
1666
+ if (!silent) toast(t('wb.loadFailed', { msg: e.message }), 'err')
1667
+ if (!state.wb.bound && /404/.test(e.message)) state.wb.apiMissing = true
1668
+ }
1669
+ const wl = await safeRpc('workspace.list', {}, '')
1670
+ state.archivedIds = wl && Array.isArray(wl.archivedSessionIds) ? wl.archivedSessionIds : []
1671
+ if (!wb) { renderWorkbench(); renderSessions(); return }
1672
+ if (!wb.bound) {
1673
+ state.wb = { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false }
1674
+ renderWorkbench()
1675
+ renderSessions()
1676
+ return
1677
+ }
1678
+ state.wb.bound = true
1679
+ state.wb.path = wb.path || ''
1680
+ state.wb.title = wb.title || ''
1681
+ if (!wl) { state.wb.projects = []; renderWorkbench(); renderSessions(); return }
1682
+ const items = Array.isArray(wl.items) ? wl.items.slice() : []
1683
+ try {
1684
+ const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1685
+ if (listRes.ok) {
1686
+ const listData = await listRes.json().catch(() => ({}))
1687
+ const have = new Set(items.map(w => wbPathKey(w.path)))
1688
+ for (const entry of listData.entries || []) {
1689
+ if (entry.type !== 'dir') continue
1690
+ const projectPath = wbJoin(state.wb.path, entry.name)
1691
+ if (have.has(wbPathKey(projectPath))) continue
1692
+ try {
1693
+ const created = await rpc('workspace.create', { path: projectPath })
1694
+ if (created?.workspace) { items.push(created.workspace); have.add(wbPathKey(projectPath)) }
1695
+ } catch {}
1696
+ }
1697
+ }
1698
+ } catch {}
1699
+ state.wb.projects = items
1700
+ .filter(w => wbStrictInside(w.path, state.wb.path))
1701
+ .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true }))
1702
+ renderWorkbench()
1703
+ renderSessions()
1704
+ }
1705
+ function renderWorkbench() {
1706
+ const box = $('workbench-box')
1707
+ if (!box) return
1708
+ const unbound = $('wb-unbound')
1709
+ const bound = $('wb-bound')
1710
+ const hint = $('wb-api-hint')
1711
+ if (!state.wb.bound) {
1712
+ unbound.classList.remove('hidden')
1713
+ bound.classList.add('hidden')
1714
+ hint?.classList.toggle('hidden', !state.wb.apiMissing)
1715
+ return
1716
+ }
1717
+ unbound.classList.add('hidden')
1718
+ bound.classList.remove('hidden')
1719
+ $('wb-head-text').textContent = t('wb.bound', { title: state.wb.title || wbBaseName(state.wb.path) })
1720
+ $('wb-head').setAttribute('aria-expanded', state.wb.expanded ? 'true' : 'false')
1721
+ $('wb-caret').textContent = state.wb.expanded ? '▾' : '▸'
1722
+ const panel = $('wb-panel')
1723
+ panel.classList.toggle('hidden', !state.wb.expanded)
1724
+ if (!state.wb.expanded) return
1725
+ const projects = state.wb.projects || []
1726
+ let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
1727
+ html += projects.length ? projects.map(w => {
1728
+ const id = String(w.workspaceId || '')
1729
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1730
+ const open = state.wb.open === id
1731
+ return `<div class="ds-wb-project ${open ? 'open' : ''}">
1732
+ <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
1733
+ <span class="ds-wb-caret" aria-hidden="true">${open ? '▾' : '▸'}</span>
1734
+ <span class="ds-wb-project-title" title="${esc(w.path)}">${esc(w.title || wbBaseName(w.path) || short(id))}</span>
1735
+ <span class="ds-wb-project-count">${sessions.length}</span>
1736
+ </button>
1737
+ <div class="ds-wb-project-body ${open ? '' : 'hidden'}">
1738
+ <button type="button" class="ds-mini-btn ds-wb-new-session" data-wb-new="${esc(id)}">+ ${esc(t('wb.newSession'))}</button>
1739
+ ${sessions.length ? sessions.map(s => `<button type="button" class="ds-wb-session ${state.current === s.sessionId ? 'current' : ''}" data-wb-session="${esc(s.sessionId)}"><span class="ds-wb-session-dot ${s.running ? 'running' : ''}"></span><span class="ds-wb-session-title">${esc(titleOf(s))}</span></button>`).join('') : `<div class="ds-wb-session-empty">${esc(t('wb.noSessions'))}</div>`}
1740
+ </div>
1741
+ </div>`
1742
+ }).join('') : `<div class="ds-wb-empty">${esc(t('wb.noProjects'))}</div>`
1743
+ html += `<button type="button" class="ds-mini-btn ds-wb-unbind-panel" data-wb-unbind-panel>${esc(t('wb.unbind'))}</button>`
1744
+ panel.innerHTML = html
1745
+ panel.querySelectorAll('[data-wb-head]').forEach(button => button.addEventListener('click', () => {
1746
+ state.wb.open = state.wb.open === button.dataset.wbHead ? null : button.dataset.wbHead
1747
+ renderWorkbench()
1748
+ }))
1749
+ panel.querySelectorAll('[data-wb-new]').forEach(button => button.addEventListener('click', async () => {
1750
+ const value = await safeRpc('session.create', { workspaceId: button.dataset.wbNew }, '')
1751
+ if (value?.sessionId) { await refreshSessions(); openSession(value.sessionId) }
1752
+ }))
1753
+ panel.querySelectorAll('[data-wb-session]').forEach(button => button.addEventListener('click', () => openSession(button.dataset.wbSession)))
1754
+ panel.querySelectorAll('[data-wb-unbind-panel]').forEach(button => button.addEventListener('click', unbindWorkbench))
1755
+ }
1756
+ const wbFs = { path: null, initial: null }
1757
+ function openWorkbenchModal() {
1758
+ $('modal-workbench').classList.remove('hidden')
1759
+ wbFs.path = null
1760
+ wbFs.initial = null
1761
+ wbFsLoad(null)
1762
+ setTimeout(() => $('wb-path-input').focus(), 50)
1763
+ }
1764
+ function closeWorkbenchModal() { $('modal-workbench').classList.add('hidden') }
1765
+ async function wbFsLoad(dir) {
1766
+ const box = $('wb-fs-list')
1767
+ const target = dir ?? wbFs.path ?? ''
1768
+ box.innerHTML = `<div class="ds-empty">${esc(t('ds.loading'))}</div>`
1769
+ $('wb-fs-path').textContent = target ? '…' + target.slice(-40) : '~'
1770
+ try {
1771
+ const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
1772
+ const data = await res.json().catch(() => ({}))
1773
+ if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
1774
+ wbFs.path = data.path
1775
+ if (!wbFs.initial) wbFs.initial = data.path
1776
+ $('wb-fs-path').textContent = data.path
1777
+ const dirs = (data.entries || []).filter(e => e.type === 'dir')
1778
+ box.innerHTML = dirs.length ? dirs.map(e => {
1779
+ const p = wbJoin(data.path, e.name)
1780
+ return `<div class="ds-wb-fs-row" data-wb-dir="${esc(p)}"><span>📁</span><span class="ds-wb-fs-name">${esc(e.name)}</span><button type="button" class="ds-btn ds-wb-select" data-wb-select="${esc(p)}">${esc(t('wb.selectDir'))}</button></div>`
1781
+ }).join('') : `<div class="ds-empty">${esc(t('wb.empty'))}</div>`
1782
+ box.querySelectorAll('[data-wb-dir]').forEach(row => row.addEventListener('click', e => { if (!e.target.closest('[data-wb-select]')) wbFsLoad(row.dataset.wbDir) }))
1783
+ box.querySelectorAll('[data-wb-select]').forEach(button => button.addEventListener('click', () => bindWorkbench(button.dataset.wbSelect)))
1784
+ } catch (e) {
1785
+ $('wb-fs-path').textContent = target || '~'
1786
+ box.innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
1787
+ }
1788
+ }
1789
+ function wbFsUp() {
1790
+ if (wbFs.path && wbFs.initial && wbFs.path !== wbFs.initial) {
1791
+ const parent = wbFsParent(wbFs.path)
1792
+ if (parent) wbFsLoad(parent)
1793
+ }
1794
+ }
1795
+ async function bindWorkbench(rawPath) {
1796
+ const value = String(rawPath || '').trim()
1797
+ if (!value) return toast(t('wb.pathEmpty'), 'err')
1798
+ try {
1799
+ const wb = await wbGateway('POST', '/workbench/bind', { path: value })
1800
+ state.wb = { bound: true, path: wb.path || value, title: wb.title || '', expanded: true, projects: null, open: null, apiMissing: false }
1801
+ const paths = [state.wb.path]
1802
+ try {
1803
+ const res = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1804
+ const data = await res.json().catch(() => ({}))
1805
+ for (const e of data.entries || []) if (e.type === 'dir') paths.push(wbJoin(state.wb.path, e.name))
1806
+ } catch {}
1807
+ for (const projectPath of paths) {
1808
+ try { await rpc('workspace.create', { path: projectPath }) } catch {}
1809
+ }
1810
+ closeWorkbenchModal()
1811
+ await refreshWorkbench({ silent: true })
1812
+ await refreshSessions()
1813
+ toast(t('wb.boundOk', { path: state.wb.path }), 'ok')
1814
+ } catch (e) {
1815
+ if (e.message === 'AUTH') return toast(t('ds.toastAuth'), 'err')
1816
+ toast(t('wb.bindFailed', { msg: e.message }), 'err')
1817
+ }
1818
+ }
1819
+ async function unbindWorkbench() {
1820
+ if (!confirm(t('wb.unbindConfirm'))) return
1821
+ try {
1822
+ await wbGateway('POST', '/workbench/unbind')
1823
+ state.wb = { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false }
1824
+ renderWorkbench()
1825
+ renderSessions()
1826
+ toast(t('wb.unboundOk'), 'ok')
1827
+ } catch (e) {
1828
+ if (e.message === 'AUTH') return toast(t('ds.toastAuth'), 'err')
1829
+ toast(t('wb.unbindFailed', { msg: e.message }), 'err')
1830
+ }
1831
+ }
1832
+ let wbRefreshTimer = null
1833
+ function scheduleWorkbenchRefresh() {
1834
+ clearTimeout(wbRefreshTimer)
1835
+ wbRefreshTimer = setTimeout(() => refreshWorkbench({ silent: true }), 400)
1836
+ }
1837
+
1595
1838
  /* ---------------- 统计 ---------------- */
1596
1839
  function bucketTokens(b) { return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0) }
1597
1840
  let statsDrawerOpened = false
@@ -1749,9 +1992,30 @@ function bindUi() {
1749
1992
  })
1750
1993
  document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
1751
1994
  $('session-list').addEventListener('click', (e) => {
1995
+ if (e.target.closest('[data-archived-toggle]')) {
1996
+ LS.set('dsShowArchivedV1', LS.get('dsShowArchivedV1', '0') === '1' ? '0' : '1')
1997
+ renderSessions()
1998
+ return
1999
+ }
1752
2000
  const item = e.target.closest('[data-id]')
1753
2001
  if (item) openSession(item.dataset.id)
1754
2002
  })
2003
+ $('btn-wb-bind').addEventListener('click', openWorkbenchModal)
2004
+ $('btn-wb-bind-manual').addEventListener('click', () => bindWorkbench($('wb-path-input').value))
2005
+ $('wb-path-input').addEventListener('keydown', e => {
2006
+ if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); bindWorkbench($('wb-path-input').value) }
2007
+ })
2008
+ $('wb-fs-up').addEventListener('click', wbFsUp)
2009
+ $('wb-fs-home').addEventListener('click', () => wbFsLoad(wbFs.initial || null))
2010
+ $('btn-wb-modal-close').addEventListener('click', closeWorkbenchModal)
2011
+ $('modal-workbench').addEventListener('click', e => { if (e.target === $('modal-workbench')) closeWorkbenchModal() })
2012
+ $('wb-head').addEventListener('click', () => {
2013
+ state.wb.expanded = !state.wb.expanded
2014
+ if (state.wb.expanded && !state.wb.projects) refreshWorkbench({ silent: false })
2015
+ else renderWorkbench()
2016
+ })
2017
+ $('btn-wb-path').addEventListener('click', () => { if (state.wb.path) toast(t('wb.boundPath', { path: state.wb.path }), 'ok') })
2018
+ $('btn-wb-unbind').addEventListener('click', unbindWorkbench)
1755
2019
  $('btn-send').addEventListener('click', sendMessage)
1756
2020
  $('composer').addEventListener('keydown', (e) => {
1757
2021
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
@@ -1885,12 +2149,14 @@ async function start() {
1885
2149
  }
1886
2150
  $('token-desc').textContent = state.token ? '● ' + state.token.slice(0, 12) + '…' : t('ds.toastAuth')
1887
2151
  bindUi()
2152
+ renderWorkbench()
1888
2153
  updateConn()
1889
2154
  checkNotesOnStart()
1890
2155
  if (state.token) {
1891
2156
  if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
1892
2157
  openStreams()
1893
2158
  refreshSessions()
2159
+ refreshWorkbench({ silent: true })
1894
2160
  }
1895
2161
  }
1896
2162
 
@@ -10,6 +10,38 @@
10
10
  if (saved === 'zh' || saved === 'en') return saved
11
11
  return (navigator.language || 'zh').toLowerCase().startsWith('zh') ? 'zh' : 'en'
12
12
  }
13
+ const BUILTIN = {
14
+ zh: {
15
+ 'wb.unbound': 'DSH Remote(未绑定)', 'wb.bind': '绑定工作台', 'wb.bound': 'DSH Remote(绑定 {title})',
16
+ 'wb.viewPath': '已绑定 workspace', 'wb.unbind': '解绑', 'wb.unbindConfirm': '确定解绑当前工作台?解绑后这些会话将回到扁平会话列表。',
17
+ 'wb.modalTitle': '绑定工作台', 'wb.modalDesc': '选择工作台根目录,其下每个子文件夹会自动成为项目工作区。',
18
+ 'wb.up': '上级', 'wb.home': '根目录', 'wb.selectDir': '选择此目录', 'wb.pathPlaceholder': '或手动输入绝对路径…',
19
+ 'wb.pathEmpty': '请输入要绑定的路径', 'wb.empty': '此目录没有子文件夹', 'wb.boundOk': '工作台已绑定:{path}',
20
+ 'wb.bindFailed': '绑定失败:{msg}', 'wb.unboundOk': '已解绑工作台', 'wb.unbindFailed': '解绑失败:{msg}',
21
+ 'wb.boundPath': '绑定路径:{path}', 'wb.projects': '项目', 'wb.noProjects': '暂无项目(根目录下新建文件夹后会自动出现)',
22
+ 'wb.newSession': '新会话', 'wb.noSessions': '暂无会话', 'wb.flatHidden': '工作台会话已收起({n} 个在工作台面板)',
23
+ 'wb.apiMissing': '工作台接口不可用(需更新网关)', 'wb.loadFailed': '工作台加载失败:{msg}',
24
+ 'wb.archivedHidden': '------隐藏已归档会话------', 'wb.archivedShown': '------显示已归档会话------'
25
+ },
26
+ en: {
27
+ 'wb.unbound': 'DSH Remote (unbound)', 'wb.bind': 'Bind workbench', 'wb.bound': 'DSH Remote (bound {title})',
28
+ 'wb.viewPath': 'Bound workspace', 'wb.unbind': 'Unbind', 'wb.unbindConfirm': 'Unbind the current workbench? These sessions will return to the flat list.',
29
+ 'wb.modalTitle': 'Bind workbench', 'wb.modalDesc': 'Choose the workbench root; each subfolder becomes a project workspace automatically.',
30
+ 'wb.up': 'Up', 'wb.home': 'Root', 'wb.selectDir': 'Select this folder', 'wb.pathPlaceholder': 'Or type an absolute path…',
31
+ 'wb.pathEmpty': 'Enter a path to bind', 'wb.empty': 'No subfolders in this folder', 'wb.boundOk': 'Workbench bound: {path}',
32
+ 'wb.bindFailed': 'Bind failed: {msg}', 'wb.unboundOk': 'Workbench unbound', 'wb.unbindFailed': 'Unbind failed: {msg}',
33
+ 'wb.boundPath': 'Bound path: {path}', 'wb.projects': 'Projects', 'wb.noProjects': 'No projects yet',
34
+ 'wb.newSession': 'New session', 'wb.noSessions': 'No sessions', 'wb.flatHidden': 'Workbench sessions folded ({n} in the workbench panel)',
35
+ 'wb.apiMissing': 'Workbench API unavailable (update gateway)', 'wb.loadFailed': 'Workbench load failed: {msg}',
36
+ 'wb.archivedHidden': '------ Hide archived ------', 'wb.archivedShown': '------ Show archived ------'
37
+ }
38
+ }
39
+ function mergeDicts(strings) {
40
+ return {
41
+ zh: Object.assign({}, BUILTIN.zh, strings?.zh || {}),
42
+ en: Object.assign({}, BUILTIN.en, strings?.en || {})
43
+ }
44
+ }
13
45
  let dict = null
14
46
  let lang = detect()
15
47
 
@@ -46,7 +78,7 @@
46
78
  }
47
79
 
48
80
  window.I18N = {
49
- init(strings) { dict = strings || dict; return apply(document) },
81
+ init(strings) { dict = mergeDicts(strings || dict || {}); return apply(document) },
50
82
  t, setLang, apply,
51
83
  get lang() { return lang }
52
84
  }