release: v0.2.2 - cross-platform paths, health version, update-check hardening, clean packaging
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css } from './styles'
|
||||
import { NS, zh, en, type WorktableKey } from './locales'
|
||||
import { isAbs, joinPath, parentPathOf, basenameOf } from './pathutil'
|
||||
import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split'
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,7 @@ type DockMode = 'footer' | 'float'
|
||||
|
||||
// ── 更新检查(客户端直连 GitHub Releases API,只读 GET;失败静默)──
|
||||
declare const __WT_VERSION__: string
|
||||
const LOCAL_VERSION = typeof __WT_VERSION__ === 'undefined' ? '0.2.0' : __WT_VERSION__
|
||||
const LOCAL_VERSION = typeof __WT_VERSION__ === 'undefined' ? 'dev' : __WT_VERSION__
|
||||
const UPDATE_REPO = 'Aisland-SJL/dsh-worktable'
|
||||
const UPGRADE_CMD = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"'
|
||||
const UPGRADE_AI = '帮我升级 dsh-worktable:执行 ' + UPGRADE_CMD + ',完成后提醒我重启 dsh web 并刷新页面'
|
||||
@@ -729,7 +730,7 @@ export type NewSessionGroup = { kind: 'none' } | { kind: 'existing'; workspaceId
|
||||
/** 插件知识包:附在窗口任务提示词里,让接收方跳过对插件源码的重新侦察,直接干活 */
|
||||
const KNOWLEDGE_PACK = [
|
||||
'【插件知识包·请直接采用,不要重新侦察插件源码】',
|
||||
'- dsh-worktable 是本机 DeepSeek Harness 的自建容器插件(仓库 E:\\AI_Workspace\\DeepseekHarness\\Projects\\dsh-worktable,插件包根目录 01_content/)。',
|
||||
'- dsh-worktable 是 DeepSeek Harness 的自建容器插件(工作台:侧边栏里的项目应用抽屉;不了解的细节可直接向用户提问)。',
|
||||
'- 窗口(内容窗)模型:每个窗 = 一个标签页;未指派时显示选择器(浏览器/动画/资源管理器/终端/✨自定义);',
|
||||
' 窗内容还可放 iframe 网页与文件预览(.md/.txt/.tsx/.css/.html 等)。',
|
||||
'- 窗口可装载的形式:HTML 单文件(交互 UI,放项目文件夹后在资源管理器点击渲染)、网页 URL、',
|
||||
@@ -783,7 +784,7 @@ export async function createCustomSession(projectId: string, projectName: string
|
||||
const parent = g.parent.replace(/[\\/]+$/, '')
|
||||
const name = g.name.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
|
||||
if (!parent || !name) throw new Error('invalid group path')
|
||||
const full = parent + '\\' + name
|
||||
const full = joinPath(parent, name)
|
||||
try { await ws.createDirectory?.(parent, name) } catch { /* 宿主 browse 能力本机为 native 时不可用 */ }
|
||||
try {
|
||||
// 兜底:插件服务端 mkdir(父目录必须已存在,避免误建深层目录)
|
||||
@@ -908,10 +909,10 @@ function buildMountContent(folder: string, d: any): any {
|
||||
const p = String(d?.path ?? '').trim()
|
||||
if (!p) return null
|
||||
if (d?.kind === 'url') return { kind: 'iframe', url: p, title: p }
|
||||
const full = p.indexOf(':') >= 0 ? p : (folder + '\\' + p.replace(/^[\\/]+/, ''))
|
||||
const full = isAbs(p) ? p : joinPath(folder, p)
|
||||
if (d?.kind === 'file') return { kind: 'file', path: full }
|
||||
const dir = full.replace(/[\\/][^\\/]*$/, '')
|
||||
const name = full.split(/[\\/]/).pop() ?? full
|
||||
const dir = parentPathOf(full)
|
||||
const name = basenameOf(full)
|
||||
return { kind: 'iframe', url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(name), title: name }
|
||||
}
|
||||
|
||||
@@ -1052,25 +1053,38 @@ function WorktableSection(props: any) {
|
||||
const [updateCheckOn, setUpdateCheckOn] = useState<boolean>(() => localStorage.getItem('dsh.worktable.updateCheck.v1') !== '0')
|
||||
const [updateCopied, setUpdateCopied] = useState(false)
|
||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'uptodate' | 'failed'>('idle')
|
||||
const updateCheckingRef = useRef(false)
|
||||
const updateAliveRef = useRef(true)
|
||||
useEffect(() => () => { updateAliveRef.current = false }, [])
|
||||
const checkUpdates = useCallback(async (force = false) => {
|
||||
if (updateCheckingRef.current) return // 防重入:并发点击只保留一个 in-flight
|
||||
const last = Number(localStorage.getItem('dsh.worktable.lastUpdateCheck.v1') ?? '0')
|
||||
if (!force && Date.now() - last < 24 * 3600 * 1000) return // 自动检查节流;手动「立即检查」绕过节流
|
||||
updateCheckingRef.current = true
|
||||
setUpdateStatus('checking')
|
||||
let d: { tag_name?: string; body?: string; html_url?: string } | null = null
|
||||
for (let attempt = 0; attempt < 3 && !d; attempt++) {
|
||||
try {
|
||||
const r = await fetch('https://api.github.com/repos/' + UPDATE_REPO + '/releases/latest', { headers: { Accept: 'application/vnd.github+json' }, cache: 'no-store' })
|
||||
if (r.ok) d = await r.json() as { tag_name?: string; body?: string; html_url?: string }
|
||||
else if (r.status === 403 || r.status === 404) break // 限流/不存在:不再重试
|
||||
} catch { /* 网络抖动:下一轮重试 */ }
|
||||
try {
|
||||
let d: { tag_name?: string; body?: string; html_url?: string } | null = null
|
||||
for (let attempt = 0; attempt < 3 && !d && updateAliveRef.current; attempt++) {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), 8000) // 单次 8s 超时,计入下一轮重试
|
||||
try {
|
||||
const r = await fetch('https://api.github.com/repos/' + UPDATE_REPO + '/releases/latest', { headers: { Accept: 'application/vnd.github+json' }, cache: 'no-store', signal: ctrl.signal })
|
||||
if (r.ok) d = await r.json() as { tag_name?: string; body?: string; html_url?: string }
|
||||
else if (r.status === 403 || r.status === 404) break // 限流/不存在:不再重试
|
||||
} catch { /* 网络抖动/超时:下一轮重试 */ }
|
||||
finally { clearTimeout(timer) }
|
||||
}
|
||||
if (!updateAliveRef.current) return // 组件已卸载:不再更新状态
|
||||
if (!d) { setUpdateStatus('failed'); return }
|
||||
localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now()))
|
||||
const tag = (d.tag_name ?? '').replace(/^v/, '')
|
||||
if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { setUpdateStatus('uptodate'); return }
|
||||
if ((localStorage.getItem('dsh.worktable.skipVersion.v1') ?? '') === tag) { setUpdateStatus('uptodate'); return }
|
||||
setUpdateInfo({ latest: tag, notes: (d.body ?? '').slice(0, 800), url: d.html_url ?? '' })
|
||||
setUpdateStatus('uptodate')
|
||||
} finally {
|
||||
updateCheckingRef.current = false
|
||||
}
|
||||
if (!d) { setUpdateStatus('failed'); return }
|
||||
localStorage.setItem('dsh.worktable.lastUpdateCheck.v1', String(Date.now()))
|
||||
const tag = (d.tag_name ?? '').replace(/^v/, '')
|
||||
if (!tag || cmpVer(tag, LOCAL_VERSION) <= 0) { setUpdateStatus('uptodate'); return }
|
||||
if ((localStorage.getItem('dsh.worktable.skipVersion.v1') ?? '') === tag) { setUpdateStatus('uptodate'); return }
|
||||
setUpdateInfo({ latest: tag, notes: (d.body ?? '').slice(0, 800), url: d.html_url ?? '' })
|
||||
setUpdateStatus('uptodate')
|
||||
}, [])
|
||||
useEffect(() => { if (updateCheckOn) void checkUpdates() }, [updateCheckOn, checkUpdates])
|
||||
const copyUpgradeAi = async () => {
|
||||
@@ -1533,7 +1547,7 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
return [
|
||||
'【为 dsh-worktable(工作台)插件增加一个新的布局预设】',
|
||||
'',
|
||||
'背景:dsh-worktable 是 DeepSeek Harness 的自建容器插件(本机仓库 dsh-worktable,插件包根目录 01_content/)。',
|
||||
'背景:dsh-worktable 是 DeepSeek Harness 的自建容器插件(本机仓库 dsh-worktable;不了解的细节可直接向用户提问)。',
|
||||
'侧边栏「工作台」区块管理项目,每个项目打开后是一个平铺工作区(若干内容窗 + 右侧对话窗)。',
|
||||
'布局预设定义在 01_content/src/client/index.tsx 的 PRESET_DEFS 数组,选择器缩略图在 presetThumb() 函数。',
|
||||
'',
|
||||
@@ -1646,10 +1660,10 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
const ws = b.workspaces as any
|
||||
const parent = consoleParent.replace(/[\\/]+$/, '')
|
||||
const name = consoleName.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
|
||||
const full = parent + '\\' + name
|
||||
const full = joinPath(parent, name)
|
||||
try { await ws?.createDirectory?.(parent, name) } catch {}
|
||||
try {
|
||||
const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ parent, name }) })
|
||||
const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: full }) })
|
||||
const d = await r.json()
|
||||
if (!r.ok || !d?.ok) throw new Error(d?.error ?? 'mkdir failed')
|
||||
} catch { /* 目录建不出的错误最终由 workspace.create 暴露 */ }
|
||||
@@ -1764,7 +1778,7 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
try {
|
||||
let raw = rawManifest
|
||||
if (raw == null) {
|
||||
const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(folder + '\\widget-result.json'), { cache: 'no-store' })
|
||||
const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(joinPath(folder, 'widget-result.json')), { cache: 'no-store' })
|
||||
if (!r.ok) return
|
||||
raw = (await r.text()).trim()
|
||||
}
|
||||
@@ -1839,7 +1853,7 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
const folder = folders[pid]
|
||||
if (!folder) continue
|
||||
try {
|
||||
const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(folder + '\\widget-result.json'), { cache: 'no-store' })
|
||||
const r = await fetch('/api/worktable/file?path=' + encodeURIComponent(joinPath(folder, 'widget-result.json')), { cache: 'no-store' })
|
||||
if (!r.ok) continue
|
||||
const raw = (await r.text()).trim()
|
||||
if (!raw) continue
|
||||
@@ -2713,7 +2727,7 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
{updateStatus === 'failed' && !updateInfo ? ' · ' + t('update.checkFail') : ''}
|
||||
</span>
|
||||
<span className="dsh-wt_versionActions">
|
||||
<button type="button" className="dsh-wt_updateBtn" onClick={() => void checkUpdates(true)}>
|
||||
<button type="button" className="dsh-wt_updateBtn" disabled={updateStatus === 'checking'} onClick={() => void checkUpdates(true)}>
|
||||
{updateStatus === 'checking' ? t('update.checking') : t('update.checkNow')}
|
||||
</button>
|
||||
<span className="dsh-wt_updateToggle" onClick={toggleUpdateCheck}>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 客户端路径小工具(纯字符串,不引第三方库):
|
||||
* 保留输入路径原有的分隔符风格,只做跨平台安全的拼接/取父/取名。
|
||||
* 覆盖:Windows 盘符根、UNC、POSIX 绝对路径与根目录、相对路径。
|
||||
*/
|
||||
|
||||
/** 绝对路径判定:盘符 C:\ 或 C:/、UNC \\server\share、POSIX / 开头 */
|
||||
export function isAbs(p: string): boolean {
|
||||
if (/^[A-Za-z]:[\\/]/.test(p)) return true
|
||||
if (p.startsWith('\\\\')) return true
|
||||
if (p.startsWith('/')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** 按 base 的分隔符风格拼接(base 含 \ 用 \,否则用 /;剥掉两端重复分隔符;盘符根与根目录不吞分隔符) */
|
||||
export function joinPath(base: string, rel: string): string {
|
||||
const relTrim = rel.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
|
||||
const sep = base.includes('\\') ? '\\' : '/'
|
||||
if (base === '/' || base === '\\') return base + relTrim
|
||||
if (/^[A-Za-z]:[\\/]$/.test(base)) return base + relTrim
|
||||
const baseTrim = base.replace(/[\\/]+$/, '')
|
||||
if (!baseTrim) return relTrim || sep
|
||||
return baseTrim + sep + relTrim
|
||||
}
|
||||
|
||||
/** 取父目录:'/foo'→'/';'C:\foo'→'C:\';'C:\'→'C:\';'/'→'/';'\\server\share\x'→'\\server\share' */
|
||||
export function parentPathOf(p: string): string {
|
||||
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
|
||||
if (idx < 0) return p
|
||||
if (idx === 0) return p[0]
|
||||
if (idx === 2 && /^[A-Za-z]:[\\/]/.test(p)) return p.slice(0, 3)
|
||||
return p.slice(0, idx)
|
||||
}
|
||||
|
||||
/** 取文件名:'a/b/c.html'→'c.html' */
|
||||
export function basenameOf(p: string): string {
|
||||
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
|
||||
return idx < 0 ? p : p.slice(idx + 1)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { parentPathOf } from './pathutil'
|
||||
import { Terminal } from 'xterm'
|
||||
|
||||
import MarkdownIt from 'markdown-it'
|
||||
@@ -253,14 +254,6 @@ async function postJson(url: string, body: unknown): Promise<any> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function parentPathOf(p: string): string {
|
||||
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
|
||||
if (idx <= 0) return p
|
||||
let parent = p.slice(0, idx)
|
||||
if (/^[A-Za-z]:$/.test(parent)) parent += '\\'
|
||||
return parent
|
||||
}
|
||||
|
||||
/** 拖拽换位暂存 */
|
||||
let dragPane: { row: PaneRow; index: number } | null = null
|
||||
/** 标签拖拽暂存(跨窗移动) */
|
||||
|
||||
Reference in New Issue
Block a user