diff --git a/README.en.md b/README.en.md index e73c875..9fb8980 100644 --- a/README.en.md +++ b/README.en.md @@ -147,6 +147,17 @@ npm start run --no-browser > Convention: automated tests validate service and API behavior only, without opening browser pages. +### Developer helper scripts + +```bash +npm run reset +npm run reset -- 79 +``` + +- `npm run reset`: prompt for a PR number; leave it blank to return to default `origin/main` +- `npm run reset -- 79`: sync directly to the latest head snapshot of PR `#79` +- The script also handles local branch switching, workspace cleanup, untracked file cleanup, and final state validation + ## Command Reference | Command | Description | diff --git a/README.md b/README.md index 14974a4..5ef9f85 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,17 @@ npm start run --no-browser > 约定:自动化测试仅验证服务与 API,不依赖打开页面。 +### 开发辅助脚本 + +```bash +npm run reset +npm run reset -- 79 +``` + +- `npm run reset`:交互输入 PR 编号;留空则回到默认 `origin/main` +- `npm run reset -- 79`:直接同步到 PR `#79` 的最新 head 快照 +- 脚本会自动完成本地分支切换、工作区清理、未跟踪文件清理与最终状态校验 + ## 命令速查 | 命令 | 说明 | diff --git a/cmd/reset-main.js b/cmd/reset-main.js index 795ec39..b6aa481 100644 --- a/cmd/reset-main.js +++ b/cmd/reset-main.js @@ -1,45 +1,193 @@ #!/usr/bin/env node /** - * Reset working tree to origin/main: - * - fetch origin/main - * - checkout main - * - hard reset to origin/main - * - clean untracked files/dirs - * - print final status + * Interactive reset workflow: + * - blank input => reset working tree to origin/main + * - PR number => fetch PR snapshot and reset local branch to that PR head + * - always discard local changes, clean untracked files/dirs, and validate + * the final branch / commit / clean-tree state * * Cross-platform: requires Node.js and git in PATH. */ const { execSync } = require('child_process'); +const readline = require('readline'); -function run(cmd) { - execSync(cmd, { stdio: 'inherit' }); +const DEFAULT_REMOTE = 'origin'; +const DEFAULT_MAIN_BRANCH = 'main'; +const DEFAULT_PR_BRANCH_PREFIX = 'pr'; + +function run(cmd, options = {}) { + const execOptions = { + stdio: 'inherit', + ...options + }; + return execSync(cmd, execOptions); +} + +function runCapture(cmd) { + return String(run(cmd, { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf-8' + }) || '').trim(); +} + +function normalizePrNumberInput(value) { + const normalized = String(value ?? '').trim(); + if (!normalized) { + return ''; + } + if (!/^\d+$/.test(normalized)) { + throw new Error(`Invalid PR number: ${normalized}`); + } + return String(Number.parseInt(normalized, 10)); +} + +function buildResetPlan({ + prNumber = '', + remote = DEFAULT_REMOTE, + mainBranch = DEFAULT_MAIN_BRANCH, + prBranchPrefix = DEFAULT_PR_BRANCH_PREFIX +} = {}) { + const normalizedPrNumber = normalizePrNumberInput(prNumber); + + if (!normalizedPrNumber) { + const targetRef = `${remote}/${mainBranch}`; + return { + mode: 'main', + title: `Reset working tree to ${targetRef}`, + targetRef, + expectedBranch: mainBranch, + expectedRef: targetRef, + steps: [ + { title: `Fetch ${targetRef}`, command: `git fetch ${remote} ${mainBranch} --prune` }, + { title: 'Discard local changes', command: 'git reset --hard' }, + { title: 'Remove untracked files', command: 'git clean -fd' }, + { title: `Checkout ${mainBranch}`, command: `git checkout ${mainBranch}` }, + { title: `Reset local branch to ${targetRef}`, command: `git reset --hard ${targetRef}` }, + { title: 'Remove untracked files', command: 'git clean -fd' }, + { title: `Validate final state against ${targetRef}`, kind: 'validate' }, + { title: 'Final status', command: 'git status --short --branch' } + ], + doneMessage: `Done. Working tree synced to ${targetRef}.` + }; + } + + const localBranch = `${prBranchPrefix}-${normalizedPrNumber}`; + const snapshotRef = `refs/remotes/${remote}/${prBranchPrefix}/${normalizedPrNumber}`; + return { + mode: 'pr', + prNumber: normalizedPrNumber, + title: `Reset working tree to PR #${normalizedPrNumber}`, + targetRef: `PR #${normalizedPrNumber}`, + expectedBranch: localBranch, + expectedRef: snapshotRef, + snapshotRef, + steps: [ + { title: `Fetch PR #${normalizedPrNumber} snapshot`, command: `git fetch ${remote} pull/${normalizedPrNumber}/head:${snapshotRef} --force` }, + { title: 'Discard local changes', command: 'git reset --hard' }, + { title: 'Remove untracked files', command: 'git clean -fd' }, + { title: `Checkout ${localBranch}`, command: `git checkout -B ${localBranch} ${snapshotRef}` }, + { title: `Reset local branch to PR #${normalizedPrNumber} snapshot`, command: `git reset --hard ${snapshotRef}` }, + { title: 'Remove untracked files', command: 'git clean -fd' }, + { title: `Validate final state against PR #${normalizedPrNumber}`, kind: 'validate' }, + { title: 'Final status', command: 'git status --short --branch' } + ], + doneMessage: `Done. Working tree synced to PR #${normalizedPrNumber} on local branch ${localBranch}.` + }; } -function main() { +function validateFinalState(plan) { + const currentBranch = runCapture('git rev-parse --abbrev-ref HEAD'); + if (currentBranch !== plan.expectedBranch) { + throw new Error(`Expected current branch ${plan.expectedBranch}, got ${currentBranch || '(unknown)'}.`); + } + + const currentHead = runCapture('git rev-parse HEAD'); + const expectedHead = runCapture(`git rev-parse ${plan.expectedRef}`); + if (currentHead !== expectedHead) { + throw new Error(`Expected HEAD ${expectedHead} from ${plan.expectedRef}, got ${currentHead}.`); + } + try { - run('git rev-parse --is-inside-work-tree'); + run('git diff --quiet HEAD --', { stdio: 'ignore' }); + run('git diff --cached --quiet HEAD --', { stdio: 'ignore' }); } catch (err) { - console.error('Not inside a git repository.'); - process.exit(1); + throw new Error('Working tree is not clean after reset.'); } - console.log('[1/5] Fetch origin/main'); - run('git fetch origin main --prune'); + const untracked = runCapture('git ls-files --others --exclude-standard'); + if (untracked) { + throw new Error(`Untracked files remain after reset:\n${untracked}`); + } +} - console.log('[2/5] Checkout main'); - run('git checkout main'); +function executeResetPlan(plan) { + console.log(plan.title); + const totalSteps = plan.steps.length; + plan.steps.forEach((step, index) => { + console.log(`[${index + 1}/${totalSteps}] ${step.title}`); + if (step.kind === 'validate') { + validateFinalState(plan); + return; + } + run(step.command); + }); + console.log(plan.doneMessage); +} - console.log('[3/5] Reset local changes to origin/main'); - run('git reset --hard origin/main'); +function resolveArgPrNumber(argv = process.argv.slice(2)) { + const first = Array.isArray(argv) ? argv.find((item) => String(item ?? '').trim()) : ''; + return normalizePrNumberInput(first || ''); +} - console.log('[4/5] Remove untracked files'); - run('git clean -fd'); +function promptForPrNumber({ stdin = process.stdin, stdout = process.stdout } = {}) { + if (!stdin || !stdout || stdin.isTTY === false || stdout.isTTY === false) { + return Promise.resolve(''); + } - console.log('[5/5] Final status'); - run('git status --short --branch'); + return new Promise((resolve, reject) => { + const rl = readline.createInterface({ input: stdin, output: stdout }); + rl.question('PR 编号(留空则重置到 origin/main): ', (answer) => { + rl.close(); + try { + resolve(normalizePrNumberInput(answer)); + } catch (err) { + reject(err); + } + }); + }); +} - console.log('Done. Working tree synced to origin/main.'); +async function main({ argv = process.argv.slice(2), stdin = process.stdin, stdout = process.stdout } = {}) { + try { + run('git rev-parse --is-inside-work-tree'); + } catch (err) { + console.error('Not inside a git repository.'); + process.exit(1); + } + + const argPrNumber = resolveArgPrNumber(argv); + const prNumber = argPrNumber || await promptForPrNumber({ stdin, stdout }); + const plan = buildResetPlan({ prNumber }); + executeResetPlan(plan); +} + +if (require.main === module) { + main().catch((err) => { + console.error(err && err.message ? err.message : err); + process.exit(1); + }); } -main(); +module.exports = { + DEFAULT_REMOTE, + DEFAULT_MAIN_BRANCH, + DEFAULT_PR_BRANCH_PREFIX, + normalizePrNumberInput, + buildResetPlan, + validateFinalState, + executeResetPlan, + resolveArgPrNumber, + promptForPrNumber, + main +}; diff --git a/tests/unit/compact-layout-ui.test.mjs b/tests/unit/compact-layout-ui.test.mjs index 75499a0..3a68a0b 100644 --- a/tests/unit/compact-layout-ui.test.mjs +++ b/tests/unit/compact-layout-ui.test.mjs @@ -2,7 +2,8 @@ import assert from 'assert'; import { readBundledWebUiCss, readProjectFile, - readBundledWebUiScript + readBundledWebUiScript, + readBundledWebUiHtml } from './helpers/web-ui-source.mjs'; test('app script includes compact layout detection and body class toggling', () => { @@ -28,9 +29,9 @@ test('styles include force-compact fallback rules for readability on touch devic assert.match(styles, /body\.force-compact\s*\{/); assert.match(styles, /body\.force-compact\s+\.app-shell\s*\{/); assert.match(styles, /body\.force-compact\s+\.status-inspector\s*\{[\s\S]*display:\s*none;/); - assert.match(styles, /body\.force-compact\s+\.top-tabs\s*\{[\s\S]*display:\s*grid\s*!important;[\s\S]*grid-template-columns:\s*repeat\(1,\s*minmax\(0,\s*1fr\)\);/); - assert.match(styles, /@media\s*\(min-width:\s*541px\)\s*\{[\s\S]*body\.force-compact\s+\.top-tabs\s*\{[\s\S]*repeat\(2,\s*minmax\(0,\s*1fr\)\);/); - assert.match(layoutShell, /@media\s*\(min-width:\s*961px\)\s*\{[\s\S]*body:not\(.force-compact\)\s+#app\s*>\s*\.top-tabs\s*\{[\s\S]*display:\s*none;/); + assert.match(styles, /body\.force-compact\s+\.top-tabs\s*\{[\s\S]*display:\s*flex\s*!important;[\s\S]*flex-wrap:\s*nowrap;[\s\S]*overflow-x:\s*auto;/); + assert.match(styles, /body\.force-compact\s+\.top-tabs::-webkit-scrollbar\s*\{[\s\S]*display:\s*none;/); + assert.match(layoutShell, /@media\s*\(min-width:\s*721px\)\s*\{[\s\S]*body:not\(.force-compact\)\s+#app\s*>\s*\.top-tabs\s*\{[\s\S]*display:\s*none;/); assert.doesNotMatch(layoutShell, /^\s*\.top-tabs\s*\{[\s\S]*display:\s*none\s*!important;/m); assert.match(styles, /body\.force-compact\s+\.card-subtitle/); const compactSubtitleBlock = styles.match(/body\.force-compact\s+\.card-subtitle\s*\{[^}]*\}/); @@ -47,10 +48,22 @@ test('styles include force-compact fallback rules for readability on touch devic test('styles keep desktop layout wide and session history readable on large screens', () => { const styles = readBundledWebUiCss(); - assert.match(styles, /\.container\s*\{[\s\S]*max-width:\s*2200px;/); + assert.match(styles, /\.container\s*\{[\s\S]*max-width:\s*none;[\s\S]*min-height:\s*100vh;/); + assert.match(styles, /\.app-shell\s*\{[\s\S]*grid-template-columns:\s*248px\s+minmax\(0,\s*1fr\);[\s\S]*min-height:\s*100vh;[\s\S]*height:\s*100vh;[\s\S]*overflow:\s*hidden;/); + assert.match(styles, /\.side-rail\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/); + assert.match(styles, /\.main-panel\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*height:\s*100vh;[\s\S]*scrollbar-width:\s*none;/); + assert.match(styles, /\.main-panel-topbar\s*\{[\s\S]*position:\s*sticky;[\s\S]*top:\s*0;/); + assert.match(styles, /\.side-item-meta\s*\{[\s\S]*display:\s*flex;[\s\S]*opacity:\s*1;/); + assert.match(styles, /\.brand-logo\s*\{[\s\S]*width:\s*38px;[\s\S]*height:\s*38px;/); + assert.match(styles, /\.content-wrapper\s*\{[\s\S]*max-width:\s*1280px;/); + assert.match(styles, /\.trash-item-actions\s*\{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(116px,\s*116px\)\);/); + assert.match(styles, /\.trash-item-actions\s+\.btn-mini\s*\{[\s\S]*height:\s*38px;[\s\S]*min-height:\s*38px;[\s\S]*white-space:\s*nowrap;/); assert.match(styles, /\.session-layout\s*\{[\s\S]*grid-template-columns:\s*minmax\(260px,\s*360px\)\s*minmax\(0,\s*1fr\);/); assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*102px;/); + const html = readBundledWebUiHtml(); + assert.match(html, /class="brand-logo"\s+src="\/res\/logo\.png"/); + const titleBlock = styles.match(/\.session-item-title\s*\{[^}]*\}/); assert.ok(titleBlock, 'missing session item title style block'); assert.match(titleBlock[0], /display:\s*-webkit-box;/); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index 575ad90..d45efbc 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -57,6 +57,8 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /sessionTrashCount/); assert.match(html, /id="side-tab-market"/); assert.match(html, /id="tab-market"/); + assert.match(html, /id="side-tab-docs"/); + assert.match(html, /id="tab-docs"/); assert.match(html, /id="side-tab-usage"/); assert.match(html, /id="tab-usage"/); assert.match(html, /data-main-tab="usage"/); @@ -73,6 +75,16 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /:aria-selected="mainTab === 'market'"/); assert.match(html, /id="panel-market"/); assert.match(html, /v-show="mainTab === 'market'"/); + assert.match(html, /data-main-tab="docs"/); + assert.match(html, /onMainTabPointerDown\('docs', \$event\)/); + assert.match(html, /onMainTabClick\('docs', \$event\)/); + assert.match(html, /aria-controls="panel-docs"/); + assert.match(html, /:aria-selected="mainTab === 'docs'"/); + assert.match(html, /id="panel-docs"/); + assert.match(html, /v-show="mainTab === 'docs'"/); + assert.match(html, /CLI 安装文档/); + assert.match(html, /installTargetCards/); + assert.match(html, /installTroubleshootingTips/); assert.doesNotMatch(html, /Skills<\/span>/); assert.doesNotMatch(html, /openSkillsManager\(\{ targetApp: 'codex' \}\)/); assert.match(html, /loadSkillsMarketOverview\(\{ forceRefresh: true, silent: false \}\)/); @@ -103,10 +115,12 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, /class="side-section" role="navigation" aria-label="配置管理"/); assert.match(html, /class="side-section" role="navigation" aria-label="会话管理"/); assert.match(html, /class="side-section" role="navigation" aria-label="技能市场"/); + assert.match(html, /class="side-section" role="navigation" aria-label="文档"/); assert.match(html, /class="side-section" role="navigation" aria-label="设置"/); assert.doesNotMatch(sideRail, /role="tablist"/); assert.doesNotMatch(sideRail, /role="tab"/); assert.match(sideRail, /id="side-tab-config-codex"[\s\S]*:aria-current="mainTab === 'config' && configMode === 'codex' \? 'page' : null"/); + assert.match(sideRail, /id="side-tab-docs"[\s\S]*:aria-current="mainTab === 'docs' \? 'page' : null"/); assert.match(sideRail, /id="side-tab-settings"[\s\S]*:aria-current="mainTab === 'settings' \? 'page' : null"/); assert.match(html, /skillsDefaultRootPath/); assert.match(html, /可直接导入/); @@ -221,7 +235,6 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(modalsBasic, / - diff --git a/web-ui/partials/index/layout-header.html b/web-ui/partials/index/layout-header.html index b9a1477..1b9c5b0 100644 --- a/web-ui/partials/index/layout-header.html +++ b/web-ui/partials/index/layout-header.html @@ -1,44 +1,4 @@
- - -
- -
- Codex Mate. -
-
- 配置中枢:管理 Codex / Claude / OpenClaw / 会话 - 本地配置中枢,统一管理 Codex / Claude Code / OpenClaw / 会话。 -
-
- -
+ @click="onConfigTabClick('codex', $event)">Codex + @click="onConfigTabClick('claude', $event)">Claude + @click="onConfigTabClick('openclaw', $event)">OpenClaw + @click="onMainTabClick('sessions', $event)">会话 + @click="onMainTabClick('market', $event)">Skills + @@ -171,9 +123,9 @@ :class="['side-item', { active: isConfigModeNavActive('claude') }]" @pointerdown="onConfigTabPointerDown('claude', $event)" @click="onConfigTabClick('claude', $event)"> -
Claude Code 配置
+
Claude Code
- Base URL / Key + Claude Settings 当前 {{ currentClaudeConfig }}
@@ -185,16 +137,16 @@ :class="['side-item', { active: isConfigModeNavActive('openclaw') }]" @pointerdown="onConfigTabPointerDown('openclaw', $event)" @click="onConfigTabClick('openclaw', $event)"> -
OpenClaw 配置
+
OpenClaw
- JSON5 / Workspace + JSON5 / AGENTS 当前 {{ currentOpenclawConfig }}
+
-
-

- {{ mainTab === 'config' ? '配置中心' : (mainTab === 'sessions' ? '会话浏览' : (mainTab === 'usage' ? 'Usage' : (mainTab === 'market' ? '技能市场' : '设置'))) }} -

-

- 配置中枢:管理 Codex / Claude / OpenClaw - 本地配置中枢,统一管理 Codex / Claude Code / OpenClaw。 -

-

- 统一查看与导出 Codex / Claude 会话。 -

-

- 单独查看本地会话用量、趋势与高频路径。 -

-

- 统一管理 Codex / Claude Skills,并聚焦本地导入与分发。 -

-
+
+
+
+
{{ mainTab === 'config' ? 'Configuration' : (mainTab === 'sessions' ? 'Sessions' : (mainTab === 'usage' ? 'Usage' : (mainTab === 'market' ? 'Skills' : (mainTab === 'docs' ? 'Docs' : 'Settings')))) }}
+

{{ mainTab === 'config' ? '本地配置控制台' : (mainTab === 'sessions' ? '会话与导出' : (mainTab === 'usage' ? '本地用量与趋势' : (mainTab === 'market' ? 'Skills 安装与同步' : (mainTab === 'docs' ? 'CLI 安装与文档' : '系统与数据设置')))) }}

+

{{ mainTab === 'config' ? '管理 Codex、Claude Code 与 OpenClaw 的本地配置、模型与运行参数。' : (mainTab === 'sessions' ? '浏览、筛选、导出与整理本地会话记录。' : (mainTab === 'usage' ? '查看近 7 / 30 天的会话、消息与来源趋势。' : (mainTab === 'market' ? '整理安装目标、导入来源与分发入口。' : (mainTab === 'docs' ? '把 CLI 安装、升级、卸载命令与排障提示集中放进正文文档页,不再依赖悬浮入口或模态框。' : '管理下载、数据目录、回收站与全局行为。')))) }}

+
+
-
- - - - -
-
-
- 当前来源 - - {{ sessionFilterSource === 'all' ? '全部' : (sessionFilterSource === 'claude' ? 'Claude Code' : 'Codex') }} - -
-
- 会话数 - {{ sessionsList.length }} -
-
-
-
- 统计范围 - {{ sessionsUsageTimeRange === '30d' ? '近 30 天' : '近 7 天' }} -
-
- 总会话数 - {{ sessionUsageSummaryCards[0]?.value ?? 0 }} -
-
- 总消息数 - {{ sessionUsageSummaryCards[1]?.value ?? 0 }} -
-
-
-
- 当前目标 - {{ skillsTargetLabel }} -
-
- 本地 Skills - {{ skillsList.length }}
-
- 可导入 - {{ skillsImportList.length }} +
+
+ 包管理器 + {{ String(installPackageManager || 'npm').toUpperCase() }} +
+
+ 当前操作 + {{ installCommandAction === 'update' ? '升级' : (installCommandAction === 'uninstall' ? '卸载' : '安装') }} +
+
+ 镜像 + {{ installRegistryPreview || '官方默认' }} +
-
- 可直接导入 - {{ skillsImportConfiguredCount }} +
+ +
-
- - -
diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html index 329d0e5..6c2718c 100644 --- a/web-ui/partials/index/modals-basic.html +++ b/web-ui/partials/index/modals-basic.html @@ -23,67 +23,6 @@
- -