diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 47fc703..ee09b1b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -4,7 +4,7 @@ const BASE = '' // 同源;dev 走 vite proxy const DEFAULT_TIMEOUT_MS = 30_000 interface RequestOptions { - method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' body?: unknown signal?: AbortSignal } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 927c28e..3a271c1 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -15,3 +15,82 @@ export class ApiException extends Error { this.body = body } } + +export type SyncStatus = 'cloning' | 'idle' | 'syncing' | 'error' +export type WorktreeStatus = 'idle' | 'active' | 'pruning' +export type CredentialKind = 'pat' | 'ssh_key' | 'none' + +export interface Workspace { + id: string + project_id: string + slug: string + name: string + description: string + git_remote_url: string + default_branch: string + sync_status: SyncStatus + last_synced_at: string | null + last_sync_error: string + created_at: string + updated_at: string +} + +export interface Worktree { + id: string + workspace_id: string + branch: string + path: string + status: WorktreeStatus + active_holder: string + acquired_at: string | null + last_used_at: string + created_at: string +} + +export interface CredentialMeta { + kind: CredentialKind + username: string + fingerprint: string +} + +export interface CredentialUpsertBody { + kind: CredentialKind + username?: string + secret?: string +} + +export interface FileStatus { + path: string + index_x: string + worktree_y: string +} + +export interface CreateWorkspaceBody { + slug: string + name: string + description?: string + git_remote_url: string + default_branch?: string + credential: CredentialUpsertBody +} + +export interface UpdateWorkspaceBody { + name?: string + description?: string + default_branch?: string +} + +export interface CreateWorktreeBody { + branch: string + base_branch?: string +} + +export interface CommitBody { + message: string + push?: boolean + add_all?: boolean +} + +export interface CommitResp { + sha: string +} diff --git a/web/src/api/workspaces.ts b/web/src/api/workspaces.ts new file mode 100644 index 0000000..c643fed --- /dev/null +++ b/web/src/api/workspaces.ts @@ -0,0 +1,68 @@ +import { request } from './client' +import type { + Workspace, + Worktree, + CredentialMeta, + CredentialUpsertBody, + FileStatus, + CreateWorkspaceBody, + UpdateWorkspaceBody, + CreateWorktreeBody, + CommitBody, + CommitResp, +} from './types' + +const enc = (v: string) => encodeURIComponent(v) + +export const workspacesApi = { + // ===== Workspace ===== + list: (projectSlug: string) => + request(`/api/v1/projects/${enc(projectSlug)}/workspaces/`), + create: (projectSlug: string, body: CreateWorkspaceBody) => + request(`/api/v1/projects/${enc(projectSlug)}/workspaces/`, { + method: 'POST', + body, + }), + get: (wsID: string) => request(`/api/v1/workspaces/${enc(wsID)}`), + update: (wsID: string, body: UpdateWorkspaceBody) => + request(`/api/v1/workspaces/${enc(wsID)}`, { method: 'PATCH', body }), + delete: (wsID: string) => + request(`/api/v1/workspaces/${enc(wsID)}`, { method: 'DELETE' }), + sync: (wsID: string) => + request(`/api/v1/workspaces/${enc(wsID)}/sync`, { method: 'POST' }), + + // ===== Credential ===== + upsertCredential: (wsID: string, body: CredentialUpsertBody) => + request(`/api/v1/workspaces/${enc(wsID)}/credential`, { + method: 'PUT', + body, + }), + getCredential: (wsID: string) => + request(`/api/v1/workspaces/${enc(wsID)}/credential`), + + // ===== Worktree ===== + listWorktrees: (wsID: string) => + request(`/api/v1/workspaces/${enc(wsID)}/worktrees`), + createWorktree: (wsID: string, body: CreateWorktreeBody) => + request(`/api/v1/workspaces/${enc(wsID)}/worktrees`, { method: 'POST', body }), + deleteWorktree: (wtID: string) => + request(`/api/v1/worktrees/${enc(wtID)}`, { method: 'DELETE' }), + acquire: (wtID: string) => + request(`/api/v1/worktrees/${enc(wtID)}/acquire`, { method: 'POST' }), + release: (wtID: string) => + request(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }), + + // ===== Git ops ===== + statusOnMain: (wsID: string) => + request(`/api/v1/workspaces/${enc(wsID)}/main/status`), + commitOnMain: (wsID: string, body: CommitBody) => + request(`/api/v1/workspaces/${enc(wsID)}/main/commit`, { method: 'POST', body }), + pushOnMain: (wsID: string) => + request(`/api/v1/workspaces/${enc(wsID)}/main/push`, { method: 'POST' }), + statusOnWorktree: (wtID: string) => + request(`/api/v1/worktrees/${enc(wtID)}/status`), + commitOnWorktree: (wtID: string, body: CommitBody) => + request(`/api/v1/worktrees/${enc(wtID)}/commit`, { method: 'POST', body }), + pushOnWorktree: (wtID: string) => + request(`/api/v1/worktrees/${enc(wtID)}/push`, { method: 'POST' }), +} diff --git a/web/src/stores/workspaces.ts b/web/src/stores/workspaces.ts new file mode 100644 index 0000000..b164e30 --- /dev/null +++ b/web/src/stores/workspaces.ts @@ -0,0 +1,174 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { workspacesApi } from '@/api/workspaces' +import type { + Workspace, + Worktree, + FileStatus, + CredentialMeta, + CredentialUpsertBody, + CreateWorkspaceBody, + UpdateWorkspaceBody, + CreateWorktreeBody, + CommitBody, +} from '@/api/types' + +export const useWorkspacesStore = defineStore('workspaces', () => { + const list = ref([]) + const current = ref(null) + const worktrees = ref([]) + const credential = ref(null) + const loading = ref(false) + + async function fetchByProject(projectSlug: string) { + loading.value = true + try { + list.value = await workspacesApi.list(projectSlug) + } finally { + loading.value = false + } + } + + async function fetchOne(wsID: string) { + current.value = await workspacesApi.get(wsID) + } + + async function create(projectSlug: string, body: CreateWorkspaceBody) { + const ws = await workspacesApi.create(projectSlug, body) + list.value.unshift(ws) + return ws + } + + async function update(wsID: string, body: UpdateWorkspaceBody) { + const ws = await workspacesApi.update(wsID, body) + if (current.value?.id === wsID) current.value = ws + // 若 wsID 不在 list 中(如深链直入 Detail 页未加载列表),map 静默 no-op;调用方按需 fetchByProject。 + list.value = list.value.map((w) => (w.id === wsID ? ws : w)) + return ws + } + + async function remove(wsID: string) { + await workspacesApi.delete(wsID) + list.value = list.value.filter((w) => w.id !== wsID) + if (current.value?.id === wsID) current.value = null + } + + async function sync(wsID: string) { + const ws = await workspacesApi.sync(wsID) + if (current.value?.id === wsID) current.value = ws + // 同 update:list 中无此 wsID 时 map 是 no-op,调用方负责拉列表。 + list.value = list.value.map((w) => (w.id === wsID ? ws : w)) + return ws + } + + /** + * 轮询 workspace.sync_status 直到落到 idle/error,用于 Phase 9 创建/Sync 后等克隆完成。 + * + * 调用方约定: + * - 若已知 ws 在 list 中,调用前请确保该项已加载(否则 list.map 静默 no-op,但 current 仍会刷新)。 + * - 失败抛出 Error,message 含最后一次观测到的 sync_status,便于 UI 区分超时类型。 + * - 调用方应自行处理并发:同一 wsID 多次并发轮询不会共享请求。 + * + * @param wsID 目标 workspace 的 UUID。 + * @param intervalMs 轮询间隔,默认 1500ms。 + * @param maxMs 总超时上限,默认 5 分钟(覆盖中等仓库克隆耗时)。 + */ + async function pollUntilSettled( + wsID: string, + intervalMs = 1500, + maxMs = 5 * 60 * 1000, + ): Promise { + const start = Date.now() + let lastStatus: Workspace['sync_status'] | 'unknown' = 'unknown' + while (Date.now() - start < maxMs) { + const ws = await workspacesApi.get(wsID) + lastStatus = ws.sync_status + if (current.value?.id === wsID) current.value = ws + // 同 update:list 中无此 wsID 时 map 是 no-op,仍刷新 current.value。 + list.value = list.value.map((w) => (w.id === wsID ? ws : w)) + if (ws.sync_status !== 'cloning' && ws.sync_status !== 'syncing') return ws + await new Promise((r) => setTimeout(r, intervalMs)) + } + throw new Error(`sync polling timed out for workspace ${wsID} (last status: ${lastStatus})`) + } + + // ===== credential ===== + async function fetchCredential(wsID: string) { + credential.value = await workspacesApi.getCredential(wsID) + } + async function upsertCredential(wsID: string, body: CredentialUpsertBody) { + credential.value = await workspacesApi.upsertCredential(wsID, body) + } + + // ===== worktrees ===== + async function fetchWorktrees(wsID: string) { + worktrees.value = await workspacesApi.listWorktrees(wsID) + } + async function createWorktree(wsID: string, body: CreateWorktreeBody) { + const wt = await workspacesApi.createWorktree(wsID, body) + worktrees.value.unshift(wt) + return wt + } + async function deleteWorktree(wtID: string) { + await workspacesApi.deleteWorktree(wtID) + worktrees.value = worktrees.value.filter((w) => w.id !== wtID) + } + async function acquire(wtID: string) { + const wt = await workspacesApi.acquire(wtID) + worktrees.value = worktrees.value.map((w) => (w.id === wtID ? wt : w)) + return wt + } + async function release(wtID: string) { + const wt = await workspacesApi.release(wtID) + worktrees.value = worktrees.value.map((w) => (w.id === wtID ? wt : w)) + return wt + } + + // ===== git ops(直透 api,store 仅负责状态变化的端点;status/commit/push 不缓存)===== + async function statusOnMain(wsID: string): Promise { + return workspacesApi.statusOnMain(wsID) + } + async function statusOnWorktree(wtID: string): Promise { + return workspacesApi.statusOnWorktree(wtID) + } + async function commitOnMain(wsID: string, body: CommitBody) { + return workspacesApi.commitOnMain(wsID, body) + } + async function commitOnWorktree(wtID: string, body: CommitBody) { + return workspacesApi.commitOnWorktree(wtID, body) + } + async function pushOnMain(wsID: string) { + return workspacesApi.pushOnMain(wsID) + } + async function pushOnWorktree(wtID: string) { + return workspacesApi.pushOnWorktree(wtID) + } + + return { + list, + current, + worktrees, + credential, + loading, + fetchByProject, + fetchOne, + create, + update, + remove, + sync, + pollUntilSettled, + fetchCredential, + upsertCredential, + fetchWorktrees, + createWorktree, + deleteWorktree, + acquire, + release, + statusOnMain, + statusOnWorktree, + commitOnMain, + commitOnWorktree, + pushOnMain, + pushOnWorktree, + } +})