feat: 项目 logo + 侧栏拖动排序 + 合并修复(主检出干净时 in-place)

- 项目 logo:仓库内文件(logo/icon/favicon 等多路径)→git remote 头像→自定义(URL/相对路径)→
  首字母徽章兜底;GET /api/projects/:id/logo(文件流/302),配置面板加 Logo 输入
- 侧栏项目拖动排序:projects.sort_order + POST /api/projects/reorder,乐观更新
- 合并修复:默认分支正被主检出占用时,若工作区干净则直接在主检出 in-place 合并
  (用户手动合并的等价操作,安全);脏工作区拒绝并提示提交/暂存或仅通过
- schema: projects.logo / sort_order(ensureColumn 平滑迁移)

测试 75/75(merge 用例改为干净→成功/脏→拒绝)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 03:45:24 +08:00
parent 740d2c2637
commit bb6186902a
12 changed files with 290 additions and 31 deletions
+77
View File
@@ -0,0 +1,77 @@
import { existsSync, statSync } from 'node:fs';
import { resolve, sep } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { Project } from '../model/types.js';
const exec = promisify(execFile);
/** logo 解析结果:file=读仓库内文件流;redirect=重定向到外链头像 */
export type LogoResolution =
| { type: 'file'; path: string }
| { type: 'redirect'; url: string }
| null;
/** 仓库内常见 logo 文件候选(按优先级),相对 repoPath */
const CANDIDATES = [
'logo.svg', 'logo.png', 'logo.webp', 'logo.jpg', 'logo.jpeg',
'icon.svg', 'icon.png', 'app_icon.png', 'favicon.svg', 'favicon.png', 'favicon.ico',
'public/logo.svg', 'public/logo.png', 'public/favicon.png', 'public/favicon.ico',
'assets/logo.svg', 'assets/logo.png', 'static/logo.svg', 'static/logo.png',
'web/favicon.png', 'web/logo.png', 'client/web/favicon.png', 'client/web/logo.png',
'src/assets/logo.svg', 'src/assets/logo.png', 'app/src/main/res/mipmap-hdpi/ic_launcher.png',
];
const URL_RE = /^https?:\/\//i;
/** 安全地把 repo 内相对路径解析为绝对路径(防穿越) */
function safeRepoPath(repoPath: string, rel: string): string | null {
const base = resolve(repoPath);
const abs = resolve(base, '.' + sep + rel);
if (abs !== base && !abs.startsWith(base + sep)) return null;
return abs;
}
/** 从 git remote 推导头像 URLGitHub owner 头像;其他 Gitea 主机尽力而为,失败由前端兜底) */
async function remoteAvatar(repoPath: string): Promise<string | null> {
let url = '';
try {
url = (await exec('git', ['-C', repoPath, 'remote', 'get-url', 'origin'])).stdout.trim();
} catch { return null; }
if (!url) return null;
// github.com/<owner>/<repo>(.git) — ssh 或 https 形式
const gh = url.match(/github\.com[:/]([^/]+)\//i);
if (gh) return `https://github.com/${gh[1]}.png?size=80`;
// 通用 Gitea<scheme|ssh>://[git@]<host>[:port]/<owner>/<repo> → https://<host>/<owner>.png(尽力,前端 onerror 兜底)
const m = url.match(/(?:https?:\/\/|ssh:\/\/[^@]*@|[^@]*@)([^/:]+)(?::\d+)?[/:]([^/]+)\//);
if (m) return `https://${m[1]}/${m[2]}.png`;
return null;
}
/**
* 解析项目 logo,优先级:
* 1. 自定义 logoproject.logo):URL → 重定向;否则视作仓库内相对路径 → 文件
* 2. 仓库内常见 logo 文件
* 3. git remote 推导头像(重定向)
* 4. null(前端用首字母徽章兜底)
*/
export async function resolveLogo(project: Project): Promise<LogoResolution> {
if (project.logo) {
if (URL_RE.test(project.logo)) return { type: 'redirect', url: project.logo };
const abs = safeRepoPath(project.repoPath, project.logo);
if (abs && existsSync(abs) && statSync(abs).isFile()) return { type: 'file', path: abs };
return null; // 自定义路径无效 → 兜底
}
for (const rel of CANDIDATES) {
const abs = resolve(project.repoPath, rel);
if (existsSync(abs) && statSync(abs).isFile()) return { type: 'file', path: abs };
}
const avatar = await remoteAvatar(project.repoPath);
if (avatar) return { type: 'redirect', url: avatar };
return null;
}
export const LOGO_MIME: Record<string, string> = {
svg: 'image/svg+xml', png: 'image/png', webp: 'image/webp',
jpg: 'image/jpeg', jpeg: 'image/jpeg', ico: 'image/x-icon', gif: 'image/gif',
};
+23
View File
@@ -9,6 +9,8 @@ import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
import { resolvedExecutorModels } from '../executor/models.js';
import { mergeBranch } from '../executor/merge.js';
import { git, removeWorktree } from '../executor/worktree.js';
import { resolveLogo, LOGO_MIME } from './logo.js';
import { createReadStream } from 'node:fs';
import { createUsageFetcher, type UsageInfo } from '../daemon/usage.js';
/** Project 出参:附加 hasTodoJson<repoPath>/todo/todo.json 是否存在,每次序列化时算) */
@@ -70,9 +72,30 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
if (b.status !== undefined) patch.status = b.status as 'active' | 'paused';
if (b.verifyCmd !== undefined) patch.verifyCmd = b.verifyCmd === null ? null : String(b.verifyCmd);
if (b.model !== undefined) patch.model = b.model === null ? null : String(b.model);
if (b.logo !== undefined) patch.logo = b.logo === null || b.logo === '' ? null : String(b.logo);
return projectOut(store.patchProject(id, patch));
});
// 项目重排序(侧栏拖动):body { order: [projectId, ...] }
app.post('/api/projects/reorder', (req) => {
const b = (req.body ?? {}) as { order?: unknown };
if (!Array.isArray(b.order)) throw new StoreError('order 必须是项目 id 数组');
return store.reorderProjects(b.order.map(String)).map(projectOut);
});
// 项目 logo:仓库内文件 → 流式返回;外链头像 → 302;无 → 404(前端用首字母徽章兜底)
app.get('/api/projects/:id/logo', async (req, reply) => {
const { id } = req.params as { id: string };
const p = store.getProject(id);
if (!p) return reply.code(404).send();
const r = await resolveLogo(p);
if (!r) return reply.code(404).send();
if (r.type === 'redirect') return reply.redirect(r.url);
const ext = r.path.split('.').pop()?.toLowerCase() ?? '';
reply.header('cache-control', 'no-cache').type(LOGO_MIME[ext] ?? 'application/octet-stream');
return reply.send(createReadStream(r.path));
});
// 单向同步 <repoPath>/todo/todo.json → maestro(导入=首次同步,幂等)
app.post('/api/projects/:id/sync', (req) => {
const { id } = req.params as { id: string };