feat: 工作区切换保活池(网页/MD滚动/激活标签不重置) + fix: 改名输入本地草稿(可先删原名,blur回显)
This commit is contained in:
@@ -289,6 +289,28 @@ function syncSessionScope(list: any) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理面板改名输入:本地草稿,blur/Enter 才提交(清空时不会立刻弹回原名) */
|
||||
function RenameInput(props: { initial: string; placeholder: string; onCommit: (v: string) => void }) {
|
||||
const [val, setVal] = useState(props.initial)
|
||||
useEffect(() => { setVal(props.initial) }, [props.initial])
|
||||
const commit = () => {
|
||||
const v = val
|
||||
props.onCommit(v)
|
||||
// 清空提交 = 删除覆盖:回显原名(与文件改名一致)
|
||||
if (!v.trim()) setVal(props.initial)
|
||||
}
|
||||
return (
|
||||
<input
|
||||
className="dsh-wt_manageInput"
|
||||
value={val}
|
||||
placeholder={props.placeholder}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function WorktableSection(props: any) {
|
||||
const wide = props.wide !== false
|
||||
const renderProjectSlot = typeof props.renderSlot === 'function' ? props.renderSlot : null
|
||||
@@ -1115,12 +1137,7 @@ function WorktableSection(props: any) {
|
||||
title={t('icons.change')}
|
||||
onClick={(e) => { e.stopPropagation(); openIconPick('project', id, e.currentTarget as HTMLElement) }}
|
||||
>{projects.iconOverrides[id] ?? meta?.icon ?? '📦'}</span>}
|
||||
<input
|
||||
className="dsh-wt_manageInput"
|
||||
value={display}
|
||||
placeholder={t('manage.renamePh')}
|
||||
onChange={(e) => renameProject(id, e.target.value)}
|
||||
/>
|
||||
<RenameInput initial={display} placeholder={t('manage.renamePh')} onCommit={(v) => renameProject(id, v)} />
|
||||
<button type="button" className="dsh-wt_manageBtn" title={isHidden ? t('manage.show') : t('manage.hide')} onClick={() => toggleHidden(id)}>
|
||||
{isHidden ? '🙈' : '👁'}
|
||||
</button>
|
||||
|
||||
@@ -1352,50 +1352,12 @@ function PanePicker(props: { row: PaneRow; index: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染) */
|
||||
function SplitWorkspace() {
|
||||
const [snap, setSnap] = useState({
|
||||
active: splitStore.active,
|
||||
spec: splitStore.spec,
|
||||
geom: splitStore.geom,
|
||||
chatW: splitStore.chatW,
|
||||
topH: splitStore.topH,
|
||||
paneWs: [...splitStore.paneWs],
|
||||
topWs: [...splitStore.topWs],
|
||||
})
|
||||
type PoolItem = { spec: LayoutSpec; chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[] }
|
||||
|
||||
useEffect(() => splitStore.subscribe(() => {
|
||||
setSnap({
|
||||
active: splitStore.active,
|
||||
spec: splitStore.spec,
|
||||
geom: splitStore.geom,
|
||||
chatW: splitStore.chatW,
|
||||
topH: splitStore.topH,
|
||||
paneWs: [...splitStore.paneWs],
|
||||
topWs: [...splitStore.topWs],
|
||||
})
|
||||
}), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!snap.active) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') splitStore.close()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [snap.active])
|
||||
|
||||
// 标签拖放高亮 → 重渲染
|
||||
const [, setDropTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const fn = () => setDropTick((t) => t + 1)
|
||||
dropTargetListeners.add(fn)
|
||||
return () => { dropTargetListeners.delete(fn) }
|
||||
}, [])
|
||||
|
||||
if (!snap.active || !snap.spec || !snap.geom) return null
|
||||
const g = snap.geom
|
||||
const spec = snap.spec
|
||||
/** 单个工作区渲染层(geom 为 null 时用 0 几何渲染,外层 display:none 保活,保留网页/MD 滚动/激活标签等状态) */
|
||||
function WorkspaceLayer(props: { spec: LayoutSpec; geom: Geom | null; chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[] }) {
|
||||
const g = props.geom ?? { left: 0, top: 0, right: 0, bottom: 0 }
|
||||
const spec = props.spec
|
||||
const top = spec.top ?? []
|
||||
const main = spec.main ?? []
|
||||
const hasLeft = !!spec.left
|
||||
@@ -1403,12 +1365,12 @@ function SplitWorkspace() {
|
||||
const chatLeft = !hasLeft && spec.chatSide === 'left'
|
||||
const colW = g.right - g.left
|
||||
const rowH = g.bottom - g.top
|
||||
const chatW = clamp(snap.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
|
||||
const chatW = clamp(props.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60))
|
||||
const topH = hasTop
|
||||
? clamp(snap.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
|
||||
? clamp(props.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
|
||||
: 0
|
||||
const leftW = hasLeft
|
||||
? clamp(snap.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260))
|
||||
? clamp(props.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260))
|
||||
: 0
|
||||
const chatFull = spec.chatFullHeight === true
|
||||
const contentW = Math.max(0, colW - chatW)
|
||||
@@ -1416,8 +1378,8 @@ function SplitWorkspace() {
|
||||
const topRowX = hasLeft ? g.left + leftW : contentX
|
||||
const topRowW = hasLeft ? Math.max(0, colW - leftW) : (chatFull ? contentW : colW)
|
||||
|
||||
const topItems = allocate(top, snap.topWs, topRowW)
|
||||
const mainItems = allocate(main, snap.paneWs, contentW)
|
||||
const topItems = allocate(top, props.topWs, topRowW)
|
||||
const mainItems = allocate(main, props.paneWs, contentW)
|
||||
const leftItem = spec.left ? { pane: spec.left, left: 0, width: leftW } : null
|
||||
|
||||
const barTop = g.top
|
||||
@@ -1542,6 +1504,95 @@ function SplitWorkspace() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染)。
|
||||
* 切换项目时旧工作区不销毁:全部挂载在池中、仅当前可见(display:none 保活),
|
||||
* 网页子页面/滚动位置、MD 滚动位置、激活标签等在切回时原样保留。 */
|
||||
function SplitWorkspace() {
|
||||
const [snap, setSnap] = useState({
|
||||
active: splitStore.active,
|
||||
spec: splitStore.spec,
|
||||
geom: splitStore.geom,
|
||||
chatW: splitStore.chatW,
|
||||
topH: splitStore.topH,
|
||||
leftW: splitStore.leftW,
|
||||
paneWs: [...splitStore.paneWs],
|
||||
topWs: [...splitStore.topWs],
|
||||
})
|
||||
const poolRef = useRef<Map<string, PoolItem>>(new Map())
|
||||
const [, setPoolTick] = useState(0)
|
||||
|
||||
useEffect(() => splitStore.subscribe(() => {
|
||||
const spec = splitStore.spec
|
||||
if (splitStore.active && spec) {
|
||||
poolRef.current.set(spec.id, {
|
||||
spec,
|
||||
chatW: splitStore.chatW,
|
||||
topH: splitStore.topH,
|
||||
leftW: splitStore.leftW,
|
||||
paneWs: [...splitStore.paneWs],
|
||||
topWs: [...splitStore.topWs],
|
||||
})
|
||||
// 保活池上限 6 个(LRU:删最老的),避免长时间使用内存膨胀
|
||||
while (poolRef.current.size > 6) {
|
||||
const first = poolRef.current.keys().next().value
|
||||
if (first != null) poolRef.current.delete(first)
|
||||
}
|
||||
}
|
||||
setSnap({
|
||||
active: splitStore.active,
|
||||
spec: splitStore.spec,
|
||||
geom: splitStore.geom,
|
||||
chatW: splitStore.chatW,
|
||||
topH: splitStore.topH,
|
||||
leftW: splitStore.leftW,
|
||||
paneWs: [...splitStore.paneWs],
|
||||
topWs: [...splitStore.topWs],
|
||||
})
|
||||
setPoolTick((t) => t + 1)
|
||||
}), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!snap.active) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') splitStore.close()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [snap.active])
|
||||
|
||||
// 标签拖放高亮 → 重渲染
|
||||
const [, setDropTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const fn = () => setDropTick((t) => t + 1)
|
||||
dropTargetListeners.add(fn)
|
||||
return () => { dropTargetListeners.delete(fn) }
|
||||
}, [])
|
||||
|
||||
const activeId = snap.active && snap.spec ? snap.spec.id : null
|
||||
const entries = Array.from(poolRef.current.entries())
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
{entries.map(([id, item]) => {
|
||||
const isActive = id === activeId
|
||||
return (
|
||||
<div key={id} style={isActive ? undefined : { display: 'none' }}>
|
||||
<WorkspaceLayer
|
||||
spec={item.spec}
|
||||
geom={isActive ? snap.geom : null}
|
||||
chatW={isActive ? snap.chatW : item.chatW}
|
||||
topH={isActive ? snap.topH : item.topH}
|
||||
leftW={isActive ? snap.leftW : item.leftW}
|
||||
paneWs={isActive ? snap.paneWs : item.paneWs}
|
||||
topWs={isActive ? snap.topWs : item.topWs}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 调试出口(自动化验证用;必须在 store 定义之后) */
|
||||
try { (window as any).__dshWorktable = { splitStore } } catch {}
|
||||
|
||||
|
||||
@@ -639,16 +639,19 @@
|
||||
原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动——
|
||||
向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目
|
||||
展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑,
|
||||
## 补记(txt/tsx/css 编辑开启 + 代码语法着色)
|
||||
## 补记(工作区切换保活 + 改名输入草稿修复)
|
||||
|
||||
- 用户需求:① txt/tsx/css 也开编辑模式;② 代码文件要有颜色(考虑开源后程序员使用)。
|
||||
- 用户反馈:① 切到其他项目再回来,网页子页面/位置、MD 滚动位置、激活标签全部重置;
|
||||
② 改名时先删原名会立刻弹回(只有先加新字才能删)。
|
||||
- 处理:
|
||||
① TextViewer 编辑/预览工具栏对所有文本类型通用(md/txt/log/tsx/ts/jsx/js/css/json),
|
||||
保存走既有 /api/worktable/write 路由;
|
||||
② 语法着色:highlight.js(只打包 core + typescript/javascript/css/json 四种语言),
|
||||
代码预览渲染成 .dsh-wt_code(token class 配色采用 GitHub Dark 风格自写 CSS,无主题依赖);
|
||||
无语言映射时 HTML 转义兜底;bundle 859KB。
|
||||
- 验证(functional-diag):STEP15 tsx 预览 hljs-keyword 493 个 token + 编辑区出现/含原文/切回预览;
|
||||
STEP11 package.json 改断言 json 高亮 31 个 token;其余全绿 ERROR_COUNT: 0;
|
||||
STEP16 写路由仍 writeRouteReady=false(等用户重启后验证保存闭环)。
|
||||
- 备注:客户端 F5 生效;保存写盘需重启一次 dsh web(上次已提示)。
|
||||
① SplitWorkspace 重构为「保活池」:按布局 id 把工作区层全部保持挂载(display:none 隐藏、
|
||||
仅当前可见;池上限 6 个 LRU)。切走再切回时 iframe 实例不销毁(子页面/滚动保留)、
|
||||
TextViewer 滚动保留、激活标签保留;几何经 props 传入(隐藏层用 0 几何)。
|
||||
重构中清理了 snap.leftW 长期缺省(clamp(undefined)→NaN)的隐患;
|
||||
② 改名输入改本地草稿(RenameInput 组件):输入不落盘,blur/Enter 才提交;
|
||||
清空提交 = 删除覆盖并回显原名。Enter 直接调 commit(无头实测发现程序化 blur() 不派发
|
||||
focusout,真实点击无碍,但 Enter 路径必须显式提交)。
|
||||
- 验证(functional-diag STEP17/18 + 多个探针):STEP17 scrollBeforeClose=320 →
|
||||
scrollAfter=320、activeAfter=1、iframeSameRef=true;STEP18 cleared=true 且真实 CDP 点击
|
||||
失焦后 revertedToOriginal=true;全 18 STEP ERROR_COUNT: 0。
|
||||
- 备注:纯客户端改动,F5 即生效。
|
||||
|
||||
@@ -282,9 +282,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\01_content\\\\src\\\\client\\\\index.tsx'});
|
||||
await new Promise(function(r){setTimeout(r,1200)});
|
||||
out.tsxTab=st.spec.main[0].tabs[0].title;
|
||||
out.tsxView=!!document.querySelector('.dsh-wt_code');
|
||||
out.tsxHljs=document.querySelectorAll('.dsh-wt_code .hljs-keyword').length;
|
||||
out.tsxHasText=document.querySelector('.dsh-wt_code')?String(document.querySelector('.dsh-wt_code').textContent).indexOf('WorktableSection')>=0:false;
|
||||
var codeEl=[].slice.call(document.querySelectorAll('.dsh-wt_code')).find(function(el){return el.getBoundingClientRect().height>0});
|
||||
out.tsxView=!!codeEl;
|
||||
out.tsxHljs=codeEl?codeEl.querySelectorAll('.hljs-keyword').length:0;
|
||||
out.tsxHasText=codeEl?String(codeEl.textContent).indexOf('WorktableSection')>=0:false;
|
||||
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\01_content\\\\src\\\\client\\\\styles.ts'});
|
||||
await new Promise(function(r){setTimeout(r,900)});
|
||||
out.cssTab=st.spec.main[0].tabs[1].title;
|
||||
@@ -510,6 +511,98 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
})()`);
|
||||
console.log('STEP16:', JSON.stringify(step16));
|
||||
|
||||
// 工作区状态保活:切换项目后激活标签/MD滚动位置/iframe 实例原样保留
|
||||
const step17 = await evaluate(`(async function(){
|
||||
var out={};
|
||||
try{
|
||||
var st=window.__dshWorktable.splitStore;
|
||||
var spec={id:'t-pool',title:'pool',top:null,main:[{id:'p1',title:'p',min:200,content:null}],chatWidth:{default:320,min:240,max:600}};
|
||||
st.open(spec);
|
||||
await new Promise(function(r){setTimeout(r,400)});
|
||||
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\02_process\\\\PRD.md'});
|
||||
await new Promise(function(r){setTimeout(r,900)});
|
||||
st.openTab('main',0,{kind:'file',path:'E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\01_content\\\\src\\\\client\\\\styles.ts'});
|
||||
await new Promise(function(r){setTimeout(r,900)});
|
||||
st.setActiveTab('main',0,st.spec.main[0].tabs[1].id);
|
||||
// 轮询等待代码视图渲染(fetch 完成后 .dsh-wt_fileView 才出现;只取可见元素,避开保活池里的隐藏层)
|
||||
var fv=null;
|
||||
for(var w=0;w<20 && !fv;w++){ await new Promise(function(r){setTimeout(r,150)}); fv=[].slice.call(document.querySelectorAll('.dsh-wt_fileView')).find(function(el){return el.getBoundingClientRect().height>0}); }
|
||||
out.fileViewReady=!!fv;
|
||||
if(fv){ fv.scrollTop=320; }
|
||||
await new Promise(function(r){setTimeout(r,200)});
|
||||
out.scrollBeforeClose=fv?fv.scrollTop:null;
|
||||
var savedSpec=st.spec;
|
||||
st.close();
|
||||
await new Promise(function(r){setTimeout(r,300)});
|
||||
st.open(savedSpec);
|
||||
await new Promise(function(r){setTimeout(r,500)});
|
||||
out.activeAfter=st.spec.main[0].active;
|
||||
var fv2=[].slice.call(document.querySelectorAll('.dsh-wt_fileView')).find(function(el){return el.getBoundingClientRect().height>0});
|
||||
out.scrollAfter=fv2?fv2.scrollTop:null;
|
||||
// iframe 保活:加一个站点 iframe 标签 → 关闭 → 重开 → 同一 DOM 实例
|
||||
var rootToken=encodeURIComponent('E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\04_test\\\\fixture-site');
|
||||
st.openTab('main',0,{kind:'iframe',url:'/api/worktable/site/'+rootToken+'/index.html',title:'site'});
|
||||
await new Promise(function(r){setTimeout(r,1500)});
|
||||
var f1=document.querySelector('.dsh-wt_paneFrame');
|
||||
var savedSpec2=st.spec;
|
||||
st.close();
|
||||
await new Promise(function(r){setTimeout(r,300)});
|
||||
st.open(savedSpec2);
|
||||
await new Promise(function(r){setTimeout(r,500)});
|
||||
var f2=document.querySelector('.dsh-wt_paneFrame');
|
||||
out.iframeSameRef=f1===f2;
|
||||
out.activeAfter2=st.spec.main[0].active;
|
||||
st.close();
|
||||
await new Promise(function(r){setTimeout(r,200)});
|
||||
}catch(err){ out.err=String(err) }
|
||||
return JSON.stringify(out);
|
||||
})()`);
|
||||
console.log('STEP17:', JSON.stringify(step17));
|
||||
|
||||
// 改名输入框:清空不会立刻弹回原名(本地草稿);真实鼠标点击别处触发 blur 提交后回显原名
|
||||
const step18pre = await evaluate(`(async function(){
|
||||
var out={};
|
||||
try{
|
||||
var btn=document.querySelector('.dsh-wt_actions .dsh-wt_iconBtn:nth-child(2)');
|
||||
if(btn){ btn.click(); }
|
||||
await new Promise(function(r){setTimeout(r,300)});
|
||||
var rows=document.querySelectorAll('.dsh-wt_settings .dsh-wt_manageRow:not(.dsh-wt_manageRowSc):not(.dsh-wt_manageRowRemoved)');
|
||||
var inp=rows[0]?rows[0].querySelector('.dsh-wt_manageInput'):null;
|
||||
if(inp){
|
||||
var setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
|
||||
setter.call(inp,'');
|
||||
inp.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
await new Promise(function(r){setTimeout(r,250)});
|
||||
out.cleared=inp.value==='';
|
||||
inp.focus();
|
||||
await new Promise(function(r){setTimeout(r,100)});
|
||||
}
|
||||
var sr=document.querySelector('.dsh-wt_sortRow').getBoundingClientRect();
|
||||
out.x=Math.round(sr.left+10);
|
||||
out.y=Math.round(sr.top+10);
|
||||
}catch(err){ out.err=String(err) }
|
||||
return JSON.stringify(out);
|
||||
})()`);
|
||||
console.log('STEP18pre:', JSON.stringify(step18pre));
|
||||
const p18 = JSON.parse(step18pre);
|
||||
if (p18 && p18.x != null && p18.y != null) {
|
||||
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: p18.x, y: p18.y, button: 'left', clickCount: 1 });
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: p18.x, y: p18.y, button: 'left', clickCount: 1 });
|
||||
await sleep(300);
|
||||
}
|
||||
const step18 = await evaluate(`(function(){
|
||||
var out={};
|
||||
try{
|
||||
var rows=document.querySelectorAll('.dsh-wt_settings .dsh-wt_manageRow:not(.dsh-wt_manageRowSc):not(.dsh-wt_manageRowRemoved)');
|
||||
var inp=rows[0]?rows[0].querySelector('.dsh-wt_manageInput'):null;
|
||||
out.revertedToOriginal=inp?inp.value!=='':false;
|
||||
var bd=document.querySelector('.dsh-wt_popBackdrop');
|
||||
if(bd){ bd.click(); }
|
||||
}catch(err){ out.err=String(err) }
|
||||
return JSON.stringify(out);
|
||||
})()`);
|
||||
console.log('STEP18:', JSON.stringify(step18));
|
||||
|
||||
// 还原临时夹具
|
||||
try { fs.writeFileSync(TEMP_MD, '# ORIG\n', 'utf8') } catch {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user