diff --git a/.env.example b/.env.example index 12fdd33..2be9b5c 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,8 @@ LLM_API_KEY= # OpenAI 兼容 Base URL,例如 https://api.openai.com/v1(可空) LLM_BASE_URL= + +# 本地 Git 仓库根。未设则用进程 cwd(从 server/ 启动时 cwd 不是仓根)。 +# 撤回会改磁盘。本地试用先跑 scripts/git-sandbox.sh,再把这里指到 tmp/git-sandbox。 +# 要操作本仓时显式写成仓根。pnpm dev:api 在沙箱存在时默认用沙箱。 +GIT_REPO= diff --git a/.gitignore b/.gitignore index cfd9e26..796402f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .env .env.local *.db +tmp/ .DS_Store node_modules .pnpm-store diff --git a/AGENTS.md b/AGENTS.md index 9e37a0a..fa99a50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,23 +2,24 @@ 修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。 -Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具 `memory_read` / `memory_write` / `memory_search`,不实现文件 / Shell / Git。 +Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具 `memory_read` / `memory_write` / `memory_search`,不实现文件 / Shell / Git **工具**。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,以及用户侧记忆查看/删除。 +- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。 - Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 - Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。 - 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。 - Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。 +- Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。 - 进程内事件总线放在 `server/internal/events`。 - 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。 - 数据库结构演进放在 `server/migrations`。 -- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`,以后 `auth/`、`memory/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。 +- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。 - 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。 -- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`)。包根 `provider.tsx` 注入 client。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 -- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。不解析 SSE。 +- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 +- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。开发态切页顶栏只放 web。不解析 SSE。 - 依赖方向:`apps/web` → `packages/views` → `packages/core`;`packages/views` → `packages/ui`。`ui` 不依赖 `core`。未来 CLI 只依赖 `core`。 - 不要创建 `server/pkg/ai`。大模型调用属于 `pkg/agent`。 diff --git a/README.md b/README.md index d3e5f9c..06108d7 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,19 @@ cp apps/web/.env.example apps/web/.env.local pnpm install ``` -API(默认 `http://localhost:8080`): +一次起 API + Web。若已有 `tmp/git-sandbox`,API 默认指到沙箱,避免在本仓上试撤回: ```bash -cd server -go run ./cmd/server +pnpm dev ``` -服务会从当前目录向上查找 `.env`,在 `server/` 下启动也能读到仓库根的 `.env`。 +也可以分开起。API(默认 `http://localhost:8080`): + +```bash +pnpm dev:api +``` + +从 `server/` 直接 `go run` 时,未设 `GIT_REPO` 会用进程 cwd(`server/` 不是仓根)。服务会从当前目录向上查找 `.env`。 Web(默认 `http://localhost:3000`): @@ -29,7 +34,7 @@ Web(默认 `http://localhost:3000`): pnpm dev:web ``` -浏览器打开 [http://localhost:3000](http://localhost:3000)。完整环境变量见 [`.env.example`](.env.example),不要提交 `.env` 或密钥。 +浏览器打开 [http://localhost:3000](http://localhost:3000)。开发态顶栏可在对话和仓库之间切换。完整环境变量见 [`.env.example`](.env.example) 和 [`apps/web/.env.example`](apps/web/.env.example),不要提交 `.env` 或密钥。 ## 测试 diff --git a/apps/web/app/(chat)/layout.tsx b/apps/web/app/(chat)/layout.tsx index e75e31b..6aa6221 100644 --- a/apps/web/app/(chat)/layout.tsx +++ b/apps/web/app/(chat)/layout.tsx @@ -4,9 +4,9 @@ import { ChatHost } from "../chat-host"; export default function ChatLayout({ children }: { children: ReactNode }) { return ( - <> +
{children} - +
); } diff --git a/apps/web/app/git-host.tsx b/apps/web/app/git-host.tsx new file mode 100644 index 0000000..97d2947 --- /dev/null +++ b/apps/web/app/git-host.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { GitClient } from "@codedock/core/git"; +import { GitPage, GitProvider } from "@codedock/views/git"; +import { useMemo } from "react"; + +import { apiBase } from "@/lib/env"; + +export function GitHost() { + const client = useMemo(() => new GitClient({ baseUrl: apiBase }), []); + + return ( + +
+ +
+
+ ); +} diff --git a/apps/web/app/git/page.tsx b/apps/web/app/git/page.tsx new file mode 100644 index 0000000..53bb88e --- /dev/null +++ b/apps/web/app/git/page.tsx @@ -0,0 +1,9 @@ +import { GitHost } from "../git-host"; + +export default function GitRoutePage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 72023f4..b9b72a9 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Providers } from "./providers"; +import { TestNav } from "./test-nav"; import "./globals.css"; const geistSans = Geist({ @@ -25,8 +26,11 @@ export default function RootLayout({ children }: LayoutProps<"/">) { lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full dark antialiased`} > - - {children} + + + {process.env.NODE_ENV === "development" ? : null} +
{children}
+
); diff --git a/apps/web/app/test-nav.tsx b/apps/web/app/test-nav.tsx new file mode 100644 index 0000000..f9e5137 --- /dev/null +++ b/apps/web/app/test-nav.tsx @@ -0,0 +1,38 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const items = [ + { href: "/", label: "对话", match: (path: string) => path === "/" || path.startsWith("/s/") }, + { href: "/git", label: "仓库", match: (path: string) => path === "/git" || path.startsWith("/git/") }, +] as const; + +export function TestNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 9e18fc7..6ebb7d3 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -5,6 +5,9 @@ const nextConfig: NextConfig = { "@codedock/core", "@codedock/ui", "@codedock/views", + "@git-diff-view/react", + "@git-diff-view/core", + "@git-diff-view/utils", "streamdown", "@streamdown/cjk", "@streamdown/code", diff --git a/docs/architecture.md b/docs/architecture.md index e8d0da1..1f10207 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ server/internal/handler |-- CRUD / SSE / Start / Continue / Cancel / 审批 --> pkg/db/sqlite |-- 领取 Run 后的 Loop --> internal/agent |-- 用户记忆查看 / 删除 --> pkg/db/sqlite + |-- Git HTTP --> pkg/git(本机 CLI,无产品流程) | v server/internal/agent @@ -37,6 +38,7 @@ server/internal/agent | v server/pkg/agent +server/pkg/git ``` `pkg/ai` 已删除。大模型调用放在 `pkg/agent`,由 `ModelConfig` 在方法内创建,不由 Runtime 注入。 @@ -55,7 +57,7 @@ CodeDock/ ├── server/ │ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配 │ ├── internal/ -│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除 +│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git │ │ ├── agent/ # 运行时编排 + sqlc 持久化 │ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引 │ │ │ └── tools/ # 具体工具定义:ping、memory_* @@ -66,6 +68,7 @@ CodeDock/ │ │ └── util/ │ ├── pkg/ │ │ ├── agent/ # 全部通用无状态逻辑,含模型调用与 Tool 抽象 +│ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用 │ │ └── db/ # Client 与 sqlc 生成代码 │ ├── migrations/ │ ├── go.mod @@ -85,6 +88,7 @@ cmd/server internal/handler -> pkg/db/sqlite.Queries -> pkg/agent # 映射响应、token 统计、Profile 装配 + -> pkg/git # 本机 Git CLI 操作 -> internal/agent # Worker 领取后的 Loop -> internal/agent/memory # 用户侧记忆响应类型 @@ -112,9 +116,13 @@ pkg/agent 不持有包级状态,不查库 Tool 包只含接口、Registry、Dispatch,不含具体工具定义 +pkg/git + 不依赖 handler、internal、sqlc + 无状态,只 exec 本机 git;不写产品流程 + packages/core 不依赖 React、Next、DOM、process.env、AI SDK - 按业务域拆目录(chat、以后的 auth / memory),不要 src/ + 按业务域拆目录(chat),不要 src/ 文件直接落在 packages/core// baseUrl / userId 由调用方注入 @@ -149,8 +157,9 @@ apps/web - 事件 JSON 回放:`GET /sessions/{id}/event-log`,供前端一次 hydrate,不替代 SSE 直播 - Run 的 Start / Continue / Retry / Cancel 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker - 同一 Session 只有一个 active Run:`interrupt` 先取消再开新 Run;`queue` 只落库,当前结束后自动领取 +- Git HTTP(`/git/*`):校验 checkout、组响应,直接调用 `pkg/git`。`GIT_REPO` 为空则用进程 cwd。`GET /git/status` 回 `SiteState` 整局(含 `is_repo`、跟踪、ahead/behind、integrating) -Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。 +Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库。 ### `internal/agent` @@ -185,6 +194,10 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。 每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。Agent 通过 `Profile.Tools.Names` 绑定工具。运行模式提供 `read` / `write` / `memory` 能力,只有模式覆盖了工具声明的全部能力时该工具才对模型可见且可 Dispatch。记忆工具声明 `memory`。审批仍由工具声明 `RequiresApproval`,`ask_for_approval` 暂停、`auto_approve` / `yolo` 自动过。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。 +### `pkg/git` + +无状态 Git CLI:`Open` / `Status`(`SiteState` 整局)/ Diff / 图 / 暂存提交 / reset / revert / 推拉 / remote / 分支 / worktree / `stash create` 副本 / 冲突读写。不进 `pkg/agent`,不写 HTTP 或产品流程。Workspace / Branch / Undo / 说明 / Agent 快照的产品组合在 Handler。 + ### `pkg/agent` 全部 Agent 通用逻辑,方法无状态: @@ -208,7 +221,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。 ### `packages/core` -跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。有鉴权再加 `auth/`,有记忆再加 `memory/`,不预建空目录。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。 +跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。有鉴权再加 `auth/`,有记忆再加 `memory/`,Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。 ### `packages/ui` @@ -222,11 +235,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。 ### `packages/views` -组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 +组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 ### `apps/web` -路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(CORS)。 +路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 ## 组装关系 @@ -248,8 +261,8 @@ Worker ## 配置 -`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。 +`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。 -HTTP 出站领域对象使用 snake_case JSON。Router 对带 Origin 的请求回显 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。 +HTTP 出站领域对象使用 snake_case JSON。Router 只对本地回环 Origin 放行 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。 修改 Agent 能力或跨端协议时,需要检查契约、取消与终态、流式事件语义以及敏感信息处理。 diff --git a/package.json b/package.json index 1bc9ce2..a2b32fd 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,8 @@ "name": "codedock", "private": true, "scripts": { + "dev": "sh scripts/dev.sh", + "dev:api": "sh scripts/dev-api.sh", "dev:web": "pnpm --filter web dev", "build:web": "pnpm --filter web build", "test:client": "pnpm --filter @codedock/core test", diff --git a/packages/core/git/client.test.ts b/packages/core/git/client.test.ts new file mode 100644 index 0000000..815b2ed --- /dev/null +++ b/packages/core/git/client.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { GitClient, GitClientError } from "./client.ts"; + +test("GitClient status and mutations hit /git routes", async () => { + const calls: { url: string; method: string; body?: unknown }[] = []; + const client = new GitClient({ + baseUrl: "http://api.test/", + fetch: async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const raw = typeof init?.body === "string" ? init.body : undefined; + calls.push({ url, method, body: raw ? JSON.parse(raw) : undefined }); + if (url.endsWith("/git/status")) { + return json({ + path: "/repo", + is_repo: true, + empty: false, + branch: "main", + head: "abc", + detached: false, + upstream: "", + ahead: 0, + behind: 0, + upstream_gone: false, + integrating: "", + default_branch: "", + files: [], + remotes: [], + }); + } + if (url.endsWith("/git/commit")) { + return json({ + commit: { id: "def", parents: ["abc"], title: "add a", body: "", author: "t", date: "" }, + }); + } + if (url.includes("/git/branches")) { + return json({ + current: "main", + locals: [], + remotes: [], + graph: { nodes: [], edges: [] }, + }); + } + if (url.includes("/git/log")) { + return json({ + commits: [{ id: "abc", parents: [], title: "first", body: "", author: "t", date: "2026-08-30T00:00:00Z" }], + }); + } + if (url.includes("/git/diff")) { + return json({ + files: [ + { + path: "a.txt", + orig_path: "", + kind: "modified", + binary: false, + patch: "@@ -1 +1 @@\n-old\n+new\n", + }, + ], + }); + } + if (url.includes("/git/commit-message/prompt")) { + return json({ + presets: [{ id: "conventional", name: "Conventional", system_prompt: "写说明" }], + selected: "conventional", + custom: "", + system_prompt: "写说明", + }); + } + if (url.includes("/git/commit-message/generate")) { + return json({ title: "add a", body: "details" }); + } + return json({ ok: true }); + }, + }); + + const state = await client.status(); + assert.equal(state.branch, "main"); + await client.stage(["a.txt"]); + await client.unstage(["a.txt"], "/wt"); + await client.discard(["a.txt"]); + const commit = await client.commit({ message: "add a", paths: ["a.txt"] }); + assert.equal(commit.id, "def"); + await client.push(); + const view = await client.listBranches("/wt"); + assert.equal(view.current, "main"); + await client.createBranch("feature", "origin/feature"); + await client.switchBranch("feature", "/wt"); + const diffs = await client.diff("worktree"); + assert.equal(diffs[0]?.path, "a.txt"); + const commits = await client.log(20, "/wt"); + assert.equal(commits[0]?.title, "first"); + const prompt = await client.messagePrompt("/wt"); + assert.equal(prompt.selected, "conventional"); + const saved = await client.saveMessagePrompt({ selected: "custom", custom: "写短标题" }); + assert.equal(saved.selected, "conventional"); + const draft = await client.generateMessage(); + assert.equal(draft.title, "add a"); + assert.equal(draft.body, "details"); + + assert.equal(calls[0]?.url, "http://api.test/git/status"); + assert.equal(calls[1]?.url, "http://api.test/git/stage"); + assert.deepEqual(calls[1]?.body, { paths: ["a.txt"], checkout: "" }); + assert.equal(calls[2]?.url, "http://api.test/git/unstage"); + assert.deepEqual(calls[2]?.body, { paths: ["a.txt"], checkout: "/wt" }); + assert.equal(calls[3]?.url, "http://api.test/git/discard"); + assert.deepEqual(calls[3]?.body, { paths: ["a.txt"], checkout: "" }); + assert.equal(calls[5]?.url, "http://api.test/git/push"); + assert.deepEqual(calls[5]?.body, { checkout: "" }); + assert.equal(calls[6]?.url, "http://api.test/git/branches?checkout=%2Fwt"); + assert.equal(calls[7]?.url, "http://api.test/git/branches"); + assert.deepEqual(calls[7]?.body, { name: "feature", start: "origin/feature", checkout: "" }); + assert.equal(calls[8]?.url, "http://api.test/git/branches/switch"); + assert.deepEqual(calls[8]?.body, { name: "feature", checkout: "/wt" }); + assert.equal(calls[9]?.url, "http://api.test/git/diff?scope=worktree"); + assert.equal(calls[10]?.url, "http://api.test/git/log?limit=20&checkout=%2Fwt"); + assert.equal(calls[11]?.url, "http://api.test/git/commit-message/prompt?checkout=%2Fwt"); + assert.equal(calls[12]?.url, "http://api.test/git/commit-message/prompt"); + assert.equal(calls[12]?.method, "PUT"); + assert.deepEqual(calls[12]?.body, { selected: "custom", custom: "写短标题" }); + assert.equal(calls[13]?.url, "http://api.test/git/commit-message/generate"); + assert.deepEqual(calls[13]?.body, { checkout: "" }); +}); + +test("GitClient maps error JSON", async () => { + const client = new GitClient({ + baseUrl: "http://api.test", + fetch: async () => + new Response(JSON.stringify({ error: "paths required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + }); + await assert.rejects(() => client.stage([]), (err: unknown) => { + assert.ok(err instanceof GitClientError); + assert.equal(err.status, 400); + assert.equal(err.message, "paths required"); + return true; + }); +}); + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/core/git/client.ts b/packages/core/git/client.ts new file mode 100644 index 0000000..a02fa3e --- /dev/null +++ b/packages/core/git/client.ts @@ -0,0 +1,168 @@ +import type { + BranchView, + Commit, + CommitRequest, + DiffFile, + DiffScope, + MessageDraft, + PromptConfig, + PromptConfigUpdate, + SiteState, +} from "./types.ts"; + +export class GitClientError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "GitClientError"; + this.status = status; + } +} + +export type GitClientOptions = { + baseUrl: string; + fetch?: typeof fetch; +}; + +export class GitClient { + readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: GitClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.fetchImpl = options.fetch ?? fetch.bind(globalThis); + } + + async status(checkout?: string): Promise { + return this.request(`/git/status${query({ checkout })}`); + } + + async stage(paths: string[], checkout?: string): Promise { + await this.request<{ ok: boolean }>("/git/stage", { + method: "POST", + json: { paths, checkout: checkout ?? "" }, + }); + } + + async unstage(paths: string[], checkout?: string): Promise { + await this.request<{ ok: boolean }>("/git/unstage", { + method: "POST", + json: { paths, checkout: checkout ?? "" }, + }); + } + + async discard(paths: string[], checkout?: string): Promise { + await this.request<{ ok: boolean }>("/git/discard", { + method: "POST", + json: { paths, checkout: checkout ?? "" }, + }); + } + + async commit(req: CommitRequest): Promise { + const body = await this.request<{ commit: Commit }>("/git/commit", { + method: "POST", + json: { + message: req.message, + paths: req.paths ?? [], + checkout: req.checkout ?? "", + }, + }); + return body.commit; + } + + async push(checkout?: string): Promise { + await this.request<{ ok: boolean }>("/git/push", { + method: "POST", + json: { checkout: checkout ?? "" }, + }); + } + + async listBranches(checkout?: string): Promise { + return this.request(`/git/branches${query({ checkout })}`); + } + + async createBranch(name: string, start?: string, checkout?: string): Promise { + await this.request<{ ok: boolean }>("/git/branches", { + method: "POST", + json: { name, start: start ?? "", checkout: checkout ?? "" }, + }); + } + + async switchBranch(name: string, checkout?: string): Promise { + await this.request<{ ok: boolean }>("/git/branches/switch", { + method: "POST", + json: { name, checkout: checkout ?? "" }, + }); + } + + async diff(scope: DiffScope = "staged", checkout?: string): Promise { + const body = await this.request<{ files?: DiffFile[] }>(`/git/diff${query({ scope, checkout })}`); + return body.files ?? []; + } + + async log(limit = 50, checkout?: string): Promise { + const body = await this.request<{ commits?: Commit[] }>( + `/git/log${query({ limit: String(limit), checkout })}`, + ); + return body.commits ?? []; + } + + async messagePrompt(checkout?: string): Promise { + return this.request(`/git/commit-message/prompt${query({ checkout })}`); + } + + async saveMessagePrompt(req: PromptConfigUpdate): Promise { + return this.request("/git/commit-message/prompt", { + method: "PUT", + json: { selected: req.selected, custom: req.custom }, + }); + } + + async generateMessage(checkout?: string): Promise { + return this.request("/git/commit-message/generate", { + method: "POST", + json: { checkout: checkout ?? "" }, + }); + } + + private async request(path: string, init: RequestInit & { json?: unknown } = {}): Promise { + const headers = new Headers(init.headers); + if (init.json !== undefined) { + headers.set("Content-Type", "application/json"); + } + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers, + body: init.json !== undefined ? JSON.stringify(init.json) : init.body, + }); + const text = await res.text(); + let parsed: unknown = undefined; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: text }; + } + } + if (!res.ok) { + const message = + parsed && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : res.statusText; + throw new GitClientError(res.status, message); + } + return parsed as T; + } +} + +function query(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value) { + search.set(key, value); + } + } + const encoded = search.toString(); + return encoded ? `?${encoded}` : ""; +} diff --git a/packages/core/git/index.ts b/packages/core/git/index.ts new file mode 100644 index 0000000..626fead --- /dev/null +++ b/packages/core/git/index.ts @@ -0,0 +1,20 @@ +export { GitClient, GitClientError, type GitClientOptions } from "./client.ts"; +export type { + Branch, + BranchView, + Commit, + CommitRequest, + DiffFile, + DiffScope, + FileStatus, + Graph, + GraphEdge, + GraphNode, + MessageDraft, + PromptConfig, + PromptConfigUpdate, + PromptPreset, + Ref, + Remote, + SiteState, +} from "./types.ts"; diff --git a/packages/core/git/types.ts b/packages/core/git/types.ts new file mode 100644 index 0000000..f125c35 --- /dev/null +++ b/packages/core/git/types.ts @@ -0,0 +1,118 @@ +export type Remote = { + name: string; + fetch_url: string; + push_url: string; +}; + +export type FileStatus = { + path: string; + orig_path: string; + staged_status: string; + worktree_status: string; + unmerged: boolean; +}; + +export type SiteState = { + path: string; + is_repo: boolean; + empty: boolean; + branch: string; + head: string; + detached: boolean; + upstream: string; + ahead: number; + behind: number; + upstream_gone: boolean; + integrating: string; + default_branch: string; + files: FileStatus[]; + remotes: Remote[]; +}; + +export type Commit = { + id: string; + parents: string[]; + title: string; + body: string; + author: string; + date: string; +}; + +export type Ref = { + name: string; + kind: "local" | "remote" | "tag" | "head" | string; +}; + +export type GraphNode = { + commit: Commit; + refs: Ref[]; +}; + +export type GraphEdge = { + child: string; + parent: string; +}; + +export type Graph = { + nodes: GraphNode[]; + edges: GraphEdge[]; +}; + +export type Branch = { + name: string; + head: string; + is_current: boolean; + is_remote: boolean; + upstream: string; + ahead: number; + behind: number; + upstream_gone: boolean; + worktree_path: string; + title: string; +}; + +export type BranchView = { + current: string; + locals: Branch[]; + remotes: Branch[]; + graph: Graph; +}; + +export type CommitRequest = { + message: string; + paths?: string[]; + checkout?: string; +}; + +export type DiffScope = "staged" | "worktree"; + +export type DiffFile = { + path: string; + orig_path: string; + kind: string; + binary: boolean; + patch: string; +}; + +export type MessageDraft = { + title: string; + body: string; +}; + +export type PromptPreset = { + id: string; + name: string; + system_prompt: string; +}; + +export type PromptConfig = { + presets: PromptPreset[]; + selected: string; + custom: string; + system_prompt: string; +}; + +export type PromptConfigUpdate = { + selected: string; + custom: string; +}; diff --git a/packages/core/index.ts b/packages/core/index.ts index f528574..5c4be59 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -19,6 +19,26 @@ export { type AgentClientOptions, type WatchEventsOptions, } from "./chat/index.ts"; +export { GitClient, GitClientError, type GitClientOptions } from "./git/index.ts"; +export type { + Branch, + BranchView, + Commit, + CommitRequest, + DiffFile, + DiffScope, + FileStatus, + Graph, + GraphEdge, + GraphNode, + MessageDraft, + PromptConfig, + PromptConfigUpdate, + PromptPreset, + Ref, + Remote, + SiteState, +} from "./git/index.ts"; export type { AgentEvent, AgentMode, diff --git a/packages/core/package.json b/packages/core/package.json index 57de2af..83a9523 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,10 +5,11 @@ "type": "module", "exports": { ".": "./index.ts", - "./chat": "./chat/index.ts" + "./chat": "./chat/index.ts", + "./git": "./git/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/sse.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/sse.test.ts git/client.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index fddf6bb..6c6e570 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -1,7 +1,7 @@ "use client"; import type { AgentMode, TimelineItem } from "@codedock/core/chat"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { useAgent } from "../provider.tsx"; import { ApprovalDock } from "./approval-dock.tsx"; @@ -16,6 +16,7 @@ export type ChatPageProps = { onOpenSession: (id: string) => void; onNewConversation: () => void; brandSrc?: string; + headerActions?: ReactNode; }; export function ChatPage({ @@ -23,6 +24,7 @@ export function ChatPage({ onOpenSession, onNewConversation, brandSrc, + headerActions, }: ChatPageProps) { const { client } = useAgent(); const list = useSessionList(); @@ -57,7 +59,7 @@ export function ChatPage({ }; return ( -
+
-
- {sessionId ? "对话" : "新对话"} +
+ {sessionId ? "对话" : "新对话"} + {headerActions ?
{headerActions}
: null}
{timeline.error || composerError ? (
diff --git a/packages/views/git/branch-switcher.tsx b/packages/views/git/branch-switcher.tsx new file mode 100644 index 0000000..85183af --- /dev/null +++ b/packages/views/git/branch-switcher.tsx @@ -0,0 +1,137 @@ +"use client"; + +import type { Branch, BranchView } from "@codedock/core/git"; +import { cn } from "@codedock/ui"; +import { ChevronDownIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { shortHead } from "./lib/status.ts"; + +export function BranchSwitcher({ + view, + busy, + onSelectLocal, + onSelectRemote, +}: { + view: BranchView; + busy: boolean; + onSelectLocal: (name: string) => Promise; + onSelectRemote: (name: string) => Promise; +}) { + const [open, setOpen] = useState(false); + const root = useRef(null); + const current = view.current || "游离 HEAD"; + + useEffect(() => { + if (!open) { + return; + } + const onPointer = (event: PointerEvent) => { + if (root.current && !root.current.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("pointerdown", onPointer); + return () => document.removeEventListener("pointerdown", onPointer); + }, [open]); + + const pick = async (kind: "local" | "remote", name: string) => { + if (busy) { + return; + } + setOpen(false); + if (kind === "local") { + if (name === view.current) { + return; + } + await onSelectLocal(name); + return; + } + await onSelectRemote(name); + }; + + return ( +
+ + {open ? ( +
+ void pick("local", name)} + /> + void pick("remote", name)} + /> +
+ ) : null} +
+ ); +} + +function BranchGroup({ + title, + empty, + items, + current, + onPick, +}: { + title: string; + empty: string; + items: Branch[]; + current: string; + onPick: (name: string) => void; +}) { + return ( +
+
{title}
+ {items.length === 0 ? ( +

{empty}

+ ) : ( +
    + {items.map((branch) => { + const active = !branch.is_remote && (branch.is_current || branch.name === current); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/packages/views/git/commit-history.tsx b/packages/views/git/commit-history.tsx new file mode 100644 index 0000000..6a4a893 --- /dev/null +++ b/packages/views/git/commit-history.tsx @@ -0,0 +1,88 @@ +"use client"; + +import type { Commit } from "@codedock/core/git"; +import { cn } from "@codedock/ui"; +import { ChevronDownIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { shortHead } from "./lib/status.ts"; + +export function CommitHistory({ commits, busy }: { commits: Commit[]; busy: boolean }) { + const [open, setOpen] = useState(false); + const root = useRef(null); + const latest = commits[0]; + + useEffect(() => { + if (!open) { + return; + } + const onPointer = (event: PointerEvent) => { + if (root.current && !root.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("pointerdown", onPointer); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onPointer); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + {open ? ( +
+
提交历史
+ {commits.length === 0 ? ( +

还没有提交

+ ) : ( +
    + {commits.map((commit, index) => ( +
  • + + {commit.title} + {index === 0 ? ( + HEAD + ) : null} + + + {shortHead(commit.id)} + {commit.author ? ` · ${commit.author}` : ""} + {commit.date ? ` · ${commit.date.slice(0, 10)}` : ""} + +
  • + ))} +
+ )} +
+ ) : null} +
+ ); +} diff --git a/packages/views/git/diff-panel.tsx b/packages/views/git/diff-panel.tsx new file mode 100644 index 0000000..58671ab --- /dev/null +++ b/packages/views/git/diff-panel.tsx @@ -0,0 +1,75 @@ +"use client"; + +import type { DiffFile as GitDiffFile } from "@codedock/core/git"; +import { DiffModeEnum, DiffView } from "@git-diff-view/react"; +import "@git-diff-view/react/styles/diff-view.css"; + +import { scopeLabel, type PreviewTarget } from "./lib/preview.ts"; + +export function DiffPanel({ + target, + file, + ready, +}: { + target: PreviewTarget | null; + file: GitDiffFile | null; + ready: boolean; +}) { + return ( +
+
+ {target ? ( + <> + {scopeLabel(target.scope)} + {target.path} + {file?.orig_path ? ( + 来自 {file.orig_path} + ) : null} + + ) : ( + 差异 + )} +
+
{content(target, file, ready)}
+
+ ); +} + +function content(target: PreviewTarget | null, file: GitDiffFile | null, ready: boolean) { + if (!target) { + return 点击左侧文件查看差异; + } + if (!ready) { + return 正在读取差异…; + } + if (!file) { + return 该文件当前没有差异; + } + if (file.binary) { + return 二进制文件,无法展示文本差异; + } + const patch = file.patch.trim(); + if (!patch) { + return 这个文件没有可展示的文本差异; + } + return ( + + ); +} + +function Empty({ children }: { children: string }) { + return

{children}

; +} diff --git a/packages/views/git/discard-confirm.tsx b/packages/views/git/discard-confirm.tsx new file mode 100644 index 0000000..c496ce3 --- /dev/null +++ b/packages/views/git/discard-confirm.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Button } from "@codedock/ui"; +import { useEffect } from "react"; + +export type DiscardRequest = { + name: string; + paths: string[]; +}; + +export function DiscardConfirm({ + request, + busy, + onCancel, + onConfirm, +}: { + request: DiscardRequest | null; + busy: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + useEffect(() => { + if (!request) { + return; + } + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !busy) { + onCancel(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [busy, onCancel, request]); + + if (!request) { + return null; + } + + const count = request.paths.length; + const summary = + count > 1 + ? `将丢掉「${request.name}」下 ${count} 个文件的工作区改动。` + : `将丢掉「${request.name}」的工作区改动。`; + + return ( +
{ + if (!busy) { + onCancel(); + } + }} + > +
event.stopPropagation()} + > +

+ 撤回更改 +

+

+ {summary} + 未跟踪的文件会被删除,已暂存的内容不受影响。此操作无法撤销。 +

+
+ + +
+
+
+ ); +} diff --git a/packages/views/git/file-tree.tsx b/packages/views/git/file-tree.tsx new file mode 100644 index 0000000..1214dd9 --- /dev/null +++ b/packages/views/git/file-tree.tsx @@ -0,0 +1,191 @@ +"use client"; + +import type { FileStatus } from "@codedock/core/git"; +import { cn } from "@codedock/ui"; +import { ChevronDownIcon, ChevronRightIcon, File, Folder, FolderOpen, Minus, Plus, Undo2 } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { isPreviewablePath } from "./lib/preview.ts"; +import { buildFileTree, collectFilePaths, type FileTreeNode } from "./lib/tree.ts"; + +const ROW_PAD = 10; +const DEPTH_STEP = 20; + +export type TreeAction = { + kind: "stage" | "unstage"; + onRun: (paths: string[]) => void; + onDiscard?: (info: { name: string; paths: string[] }) => void; +}; + +export function FileTree({ + files, + labelOf, + empty, + activePath, + busy, + action, + onPreview, +}: { + files: FileStatus[]; + labelOf: (file: FileStatus) => string; + empty: string; + activePath?: string | null; + busy?: boolean; + action?: TreeAction; + onPreview?: (path: string) => void; +}) { + const tree = useMemo(() => buildFileTree(files), [files]); + if (files.length === 0) { + return

{empty}

; + } + return ( +
    + {tree.map((node) => ( + + ))} +
+ ); +} + +function TreeNode({ + node, + depth, + activePath, + busy, + labelOf, + action, + onPreview, +}: { + node: FileTreeNode; + depth: number; + activePath?: string | null; + busy?: boolean; + labelOf: (file: FileStatus) => string; + action?: TreeAction; + onPreview?: (path: string) => void; +}) { + const [open, setOpen] = useState(true); + const paths = collectFilePaths(node); + const file = node.file; + const expandable = !file || node.children.length > 0; + const previewable = Boolean(file && isPreviewablePath(file.path)); + const active = Boolean(file && activePath === file.path); + const padding = ROW_PAD + depth * DEPTH_STEP; + + return ( +
  • +
    { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + if (expandable) { + setOpen((prev) => !prev); + return; + } + if (previewable && file) { + onPreview?.(file.path); + } + }} + onClick={() => { + if (expandable) { + setOpen((prev) => !prev); + return; + } + if (previewable && file) { + onPreview?.(file.path); + } + }} + > + {expandable ? ( + + {open ? : } + + ) : ( + + )} + {previewable ? ( + + ) : open && expandable ? ( + + ) : ( + + )} + + {node.name} + {file?.orig_path ? ( + 来自 {file.orig_path} + ) : null} + + {file ? ( + {labelOf(file)} + ) : ( + {paths.length} + )} + {action ? ( + + ) : null} + {action?.onDiscard ? ( + + ) : null} +
    + {expandable && open ? ( +
      + {node.children.map((child) => ( + + ))} +
    + ) : null} +
  • + ); +} diff --git a/packages/views/git/git-page.tsx b/packages/views/git/git-page.tsx new file mode 100644 index 0000000..c499695 --- /dev/null +++ b/packages/views/git/git-page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Button } from "@codedock/ui"; +import { useMemo, useState, type ReactNode } from "react"; + +import { BranchSwitcher } from "./branch-switcher.tsx"; +import { CommitHistory } from "./commit-history.tsx"; +import { DiffPanel } from "./diff-panel.tsx"; +import { useGitSite } from "./hooks/use-git-site.ts"; +import type { PreviewTarget } from "./lib/preview.ts"; +import { shortHead, trackLabel } from "./lib/status.ts"; +import { WorkspacePanel } from "./workspace-panel.tsx"; + +export type GitPageProps = { + onBack?: () => void; + headerActions?: ReactNode; +}; + +export function GitPage({ onBack, headerActions }: GitPageProps) { + const site = useGitSite(); + const [preview, setPreview] = useState(null); + const previewFile = useMemo(() => { + if (!preview) { + return null; + } + const list = preview.scope === "staged" ? site.diffs.staged : site.diffs.worktree; + return list.find((file) => file.path === preview.path) ?? null; + }, [preview, site.diffs]); + + return ( +
    +
    +
    仓库
    + {site.state.is_repo ? ( + <> + + +
    + {site.state.path || "—"} + {site.state.head ? ` · ${shortHead(site.state.head)}` : ""} + {site.state.upstream + ? ` · ${trackLabel(site.state.ahead, site.state.behind, site.state.upstream, site.state.upstream_gone)}` + : ""} + {site.state.integrating ? ` · 正在 ${site.state.integrating}` : ""} +
    + + ) : null} +
    + {headerActions} + {onBack ? ( + + ) : null} +
    +
    + {site.error ? ( +
    + {site.error} +
    + ) : null} +
    + {site.loading ? ( +

    正在读取仓库…

    + ) : !site.state.is_repo ? ( +

    + 当前文件夹还不是 Git 仓库。把 GIT_REPO 指到仓根后再打开。 +

    + ) : ( + <> + + + + )} +
    +
    + ); +} diff --git a/packages/views/git/hooks/use-git-site.ts b/packages/views/git/hooks/use-git-site.ts new file mode 100644 index 0000000..c53b6b6 --- /dev/null +++ b/packages/views/git/hooks/use-git-site.ts @@ -0,0 +1,216 @@ +"use client"; + +import type { BranchView, Commit, DiffFile, MessageDraft, PromptConfig, SiteState } from "@codedock/core/git"; +import { useCallback, useEffect, useState } from "react"; + +import { localNameFromRemote } from "../lib/status.ts"; +import { useGit } from "../provider.tsx"; + +const emptyState = (): SiteState => ({ + path: "", + is_repo: false, + empty: true, + branch: "", + head: "", + detached: false, + upstream: "", + ahead: 0, + behind: 0, + upstream_gone: false, + integrating: "", + default_branch: "", + files: [], + remotes: [], +}); + +const emptyBranches = (): BranchView => ({ + current: "", + locals: [], + remotes: [], + graph: { nodes: [], edges: [] }, +}); + +const emptyDiffs = (): { staged: DiffFile[]; worktree: DiffFile[] } => ({ + staged: [], + worktree: [], +}); + +export function useGitSite() { + const { client } = useGit(); + const [state, setState] = useState(emptyState); + const [branches, setBranches] = useState(emptyBranches); + const [diffs, setDiffs] = useState(emptyDiffs); + const [commits, setCommits] = useState([]); + const [prompt, setPrompt] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [generating, setGenerating] = useState(false); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const nextState = await client.status(); + setState(nextState); + const extras = await Promise.allSettled([ + client.listBranches(), + client.diff("staged"), + client.diff("worktree"), + client.log(), + ]); + const [nextBranches, staged, worktree, nextCommits] = extras; + if (nextBranches.status === "fulfilled") { + setBranches({ + ...nextBranches.value, + locals: nextBranches.value.locals ?? [], + remotes: nextBranches.value.remotes ?? [], + graph: nextBranches.value.graph ?? { nodes: [], edges: [] }, + }); + } + if (staged.status === "fulfilled" || worktree.status === "fulfilled") { + setDiffs((prev) => ({ + staged: staged.status === "fulfilled" ? staged.value : prev.staged, + worktree: worktree.status === "fulfilled" ? worktree.value : prev.worktree, + })); + } + if (nextCommits.status === "fulfilled") { + setCommits(nextCommits.value); + } + const failed = extras.find((item) => item.status === "rejected"); + if (failed && failed.status === "rejected") { + setError(failed.reason instanceof Error ? failed.reason.message : "无法读取仓库"); + return; + } + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "无法读取仓库"); + } finally { + setLoading(false); + } + }, [client]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + let cancelled = false; + void client + .messagePrompt() + .then((next) => { + if (!cancelled) { + setPrompt(next); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "无法读取提示词"); + } + }); + return () => { + cancelled = true; + }; + }, [client]); + + const run = useCallback( + async (action: () => Promise) => { + setBusy(true); + try { + await action(); + await refresh(); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "操作失败"); + throw err; + } finally { + setBusy(false); + } + }, + [refresh], + ); + + const stage = useCallback((paths: string[]) => run(() => client.stage(paths)), [client, run]); + const unstage = useCallback((paths: string[]) => run(() => client.unstage(paths)), [client, run]); + const discard = useCallback((paths: string[]) => run(() => client.discard(paths)), [client, run]); + const commit = useCallback( + (message: string) => run(() => client.commit({ message, paths: [] })), + [client, run], + ); + const push = useCallback(() => run(() => client.push()), [client, run]); + const switchBranch = useCallback( + (name: string) => run(() => client.switchBranch(name)), + [client, run], + ); + const reload = useCallback(async () => { + setBusy(true); + try { + await refresh(); + } finally { + setBusy(false); + } + }, [refresh]); + + const generate = useCallback(async (): Promise => { + setGenerating(true); + try { + const draft = await client.generateMessage(); + setError(null); + return draft; + } catch (err) { + setError(err instanceof Error ? err.message : "生成失败"); + throw err; + } finally { + setGenerating(false); + } + }, [client]); + + const savePrompt = useCallback( + async (selected: string, custom: string) => { + try { + const next = await client.saveMessagePrompt({ selected, custom }); + setPrompt(next); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "无法保存提示词"); + throw err; + } + }, + [client], + ); + + const checkoutRemote = useCallback( + (remoteName: string) => + run(async () => { + const local = localNameFromRemote(remoteName); + const listed = await client.listBranches(); + const exists = (listed.locals ?? []).some((branch) => branch.name === local); + if (!exists) { + await client.createBranch(local, remoteName); + } + await client.switchBranch(local); + }), + [client, run], + ); + + return { + state, + branches, + diffs, + commits, + prompt, + error, + busy, + generating, + loading, + refresh, + reload, + stage, + unstage, + discard, + commit, + generate, + savePrompt, + push, + switchBranch, + checkoutRemote, + }; +} diff --git a/packages/views/git/index.ts b/packages/views/git/index.ts new file mode 100644 index 0000000..a22b476 --- /dev/null +++ b/packages/views/git/index.ts @@ -0,0 +1,3 @@ +export { GitPage, type GitPageProps } from "./git-page.tsx"; +export { GitProvider, useGit } from "./provider.tsx"; +export { useGitSite } from "./hooks/use-git-site.ts"; diff --git a/packages/views/git/lib/preview.ts b/packages/views/git/lib/preview.ts new file mode 100644 index 0000000..6adda4f --- /dev/null +++ b/packages/views/git/lib/preview.ts @@ -0,0 +1,14 @@ +import type { DiffScope } from "@codedock/core/git"; + +export type PreviewTarget = { + path: string; + scope: DiffScope; +}; + +export function isPreviewablePath(path: string): boolean { + return path !== "" && !path.endsWith("/"); +} + +export function scopeLabel(scope: DiffScope): string { + return scope === "staged" ? "已暂存" : "当前目录"; +} diff --git a/packages/views/git/lib/status.ts b/packages/views/git/lib/status.ts new file mode 100644 index 0000000..e112347 --- /dev/null +++ b/packages/views/git/lib/status.ts @@ -0,0 +1,80 @@ +import type { FileStatus } from "@codedock/core/git"; + +export function letterDirty(letter: string): boolean { + return letter !== "" && letter !== " " && letter !== "."; +} + +export function isStaged(file: FileStatus): boolean { + return letterDirty(file.staged_status); +} + +export function isWorktreeDirty(file: FileStatus): boolean { + return letterDirty(file.worktree_status); +} + +export function splitWorkspaceFiles(files: FileStatus[]): { + staged: FileStatus[]; + worktree: FileStatus[]; +} { + return { + staged: files.filter((file) => isStaged(file) && !file.unmerged), + worktree: files.filter((file) => isWorktreeDirty(file) || file.unmerged), + }; +} + +export function stagedLabel(file: FileStatus): string { + if (file.unmerged) { + return "冲突"; + } + switch (file.staged_status) { + case "A": + return "新增"; + case "D": + return "删除"; + case "R": + return "重命名"; + case "C": + return "复制"; + default: + return "已修改"; + } +} + +export function worktreeLabel(file: FileStatus): string { + if (file.unmerged) { + return "冲突"; + } + if (file.worktree_status === "?") { + return "未跟踪"; + } + if (file.worktree_status === "D") { + return "删除"; + } + return "已修改"; +} + +export function localNameFromRemote(remoteName: string): string { + const slash = remoteName.indexOf("/"); + return slash === -1 ? remoteName : remoteName.slice(slash + 1); +} + +export function shortHead(head: string): string { + return head.slice(0, 8); +} + +export function trackLabel(ahead: number, behind: number, upstream: string, gone: boolean): string { + if (!upstream) { + return "未跟踪远程"; + } + if (gone) { + return `${upstream} 已删除`; + } + const bits: string[] = []; + if (ahead > 0) { + bits.push(`超前 ${ahead}`); + } + if (behind > 0) { + bits.push(`落后 ${behind}`); + } + return bits.length > 0 ? `${upstream} · ${bits.join(" · ")}` : upstream; +} diff --git a/packages/views/git/lib/tree.test.ts b/packages/views/git/lib/tree.test.ts new file mode 100644 index 0000000..4562bf3 --- /dev/null +++ b/packages/views/git/lib/tree.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { isPreviewablePath } from "./preview.ts"; +import { localNameFromRemote, splitWorkspaceFiles } from "./status.ts"; +import { buildFileTree, collectFilePaths } from "./tree.ts"; + +function file( + path: string, + staged = " ", + worktree = "M", +): { + path: string; + orig_path: string; + staged_status: string; + worktree_status: string; + unmerged: boolean; +} { + return { + path, + orig_path: "", + staged_status: staged, + worktree_status: worktree, + unmerged: false, + }; +} + +test("buildFileTree groups files under directories", () => { + const tree = buildFileTree([ + file("apps/web/app/page.tsx"), + file("apps/web/package.json"), + file("README.md"), + ]); + assert.equal(tree.length, 2); + assert.equal(tree[0]?.name, "apps"); + assert.equal(tree[0]?.children[0]?.name, "web"); + assert.deepEqual( + tree[0]?.children[0]?.children.map((node) => node.name), + ["app", "package.json"], + ); + assert.equal(tree[1]?.name, "README.md"); + assert.equal(tree[1]?.file?.path, "README.md"); +}); + +test("collectFilePaths walks a folder", () => { + const tree = buildFileTree([file("a/b.txt"), file("a/c.txt")]); + assert.deepEqual(collectFilePaths(tree[0]!), ["a/b.txt", "a/c.txt"]); +}); + +test("splitWorkspaceFiles separates staged and worktree", () => { + const split = splitWorkspaceFiles([ + file("staged.ts", "M", " "), + file("dirty.ts", " ", "M"), + file("both.ts", "M", "M"), + { ...file("conflict.ts", "U", "U"), unmerged: true }, + ]); + assert.deepEqual( + split.staged.map((item) => item.path), + ["staged.ts", "both.ts"], + ); + assert.deepEqual( + split.worktree.map((item) => item.path), + ["dirty.ts", "both.ts", "conflict.ts"], + ); +}); + +test("buildFileTree treats trailing-slash paths as folders", () => { + const tree = buildFileTree([file("apps/web/app/git/")]); + const git = tree[0]?.children[0]?.children[0]?.children[0]; + assert.equal(git?.name, "git"); + assert.equal(git?.file, undefined); + assert.deepEqual(git?.children, []); +}); + +test("buildFileTree nests untracked files under folders", () => { + const tree = buildFileTree([ + file("packages/views/git/file-tree.tsx", " ", "?"), + file("packages/views/git/git-page.tsx", " ", "?"), + ]); + const git = tree[0]?.children[0]?.children[0]; + assert.equal(git?.name, "git"); + assert.equal(git?.file, undefined); + assert.deepEqual( + git?.children.map((node) => node.name), + ["file-tree.tsx", "git-page.tsx"], + ); +}); + +test("localNameFromRemote strips the remote prefix", () => { + assert.equal(localNameFromRemote("origin/feat/git"), "feat/git"); + assert.equal(localNameFromRemote("main"), "main"); +}); + +test("isPreviewablePath skips folders", () => { + assert.equal(isPreviewablePath("apps/web/app/page.tsx"), true); + assert.equal(isPreviewablePath("apps/web/app/git/"), false); + assert.equal(isPreviewablePath(""), false); +}); diff --git a/packages/views/git/lib/tree.ts b/packages/views/git/lib/tree.ts new file mode 100644 index 0000000..879e86e --- /dev/null +++ b/packages/views/git/lib/tree.ts @@ -0,0 +1,68 @@ +import type { FileStatus } from "@codedock/core/git"; + +export type FileTreeNode = { + name: string; + path: string; + file?: FileStatus; + children: FileTreeNode[]; +}; + +export function buildFileTree(files: FileStatus[]): FileTreeNode[] { + const root: FileTreeNode[] = []; + const dirs = new Map(); + + const ensureDir = (dirPath: string): FileTreeNode[] => { + if (!dirPath) { + return root; + } + const existing = dirs.get(dirPath); + if (existing) { + return existing.children; + } + const parts = dirPath.split("/"); + const name = parts[parts.length - 1] ?? dirPath; + const parentPath = parts.slice(0, -1).join("/"); + const node: FileTreeNode = { name, path: dirPath, children: [] }; + dirs.set(dirPath, node); + ensureDir(parentPath).push(node); + return node.children; + }; + + const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path)); + for (const file of sorted) { + const normalized = file.path.replace(/\/+$/, ""); + if (!normalized) { + continue; + } + if (file.path.endsWith("/")) { + ensureDir(normalized); + continue; + } + const parts = normalized.split("/").filter(Boolean); + const name = parts[parts.length - 1] ?? normalized; + const dir = parts.slice(0, -1).join("/"); + ensureDir(dir).push({ name, path: file.path, file, children: [] }); + } + + const sortNodes = (nodes: FileTreeNode[]) => { + nodes.sort((a, b) => { + const dirFirst = Number(Boolean(a.file)) - Number(Boolean(b.file)); + if (dirFirst !== 0) { + return dirFirst; + } + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + sortNodes(node.children); + } + }; + sortNodes(root); + return root; +} + +export function collectFilePaths(node: FileTreeNode): string[] { + if (node.file) { + return [node.file.path]; + } + return node.children.flatMap(collectFilePaths); +} diff --git a/packages/views/git/prompt-picker.tsx b/packages/views/git/prompt-picker.tsx new file mode 100644 index 0000000..4eaae7c --- /dev/null +++ b/packages/views/git/prompt-picker.tsx @@ -0,0 +1,123 @@ +"use client"; + +import type { PromptConfig } from "@codedock/core/git"; +import { cn } from "@codedock/ui"; +import { ChevronDownIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +export function PromptPicker({ + prompt, + disabled, + onSelect, + onSaveCustom, +}: { + prompt: PromptConfig | null; + disabled: boolean; + onSelect: (selected: string) => Promise; + onSaveCustom: (custom: string) => Promise; +}) { + const [open, setOpen] = useState(false); + const [custom, setCustom] = useState(prompt?.custom ?? ""); + const root = useRef(null); + const selected = prompt?.selected ?? "conventional"; + const label = prompt?.presets.find((item) => item.id === selected)?.name ?? "提示词"; + + const persistCustom = () => { + if (selected === "custom" && custom !== (prompt?.custom ?? "")) { + void onSaveCustom(custom); + } + }; + + const close = () => { + persistCustom(); + setOpen(false); + }; + + useEffect(() => { + setCustom(prompt?.custom ?? ""); + }, [prompt?.custom]); + + useEffect(() => { + if (!open) { + return; + } + const onPointer = (event: PointerEvent) => { + if (root.current && !root.current.contains(event.target as Node)) { + close(); + } + }; + document.addEventListener("pointerdown", onPointer); + return () => document.removeEventListener("pointerdown", onPointer); + }, [open, custom, selected, prompt?.custom]); + + return ( +
    + + {open && prompt ? ( +
    +
    生成说明用的提示词
    +
      + {prompt.presets.map((preset) => { + const active = preset.id === selected; + return ( +
    • + +
    • + ); + })} +
    + {selected === "custom" ? ( +
    +