feat(web): api client and pinia store for workspace/worktree

This commit is contained in:
2026-05-02 20:27:51 +08:00
parent 0cadec343c
commit deaa738bf9
4 changed files with 322 additions and 1 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ const BASE = '' // 同源;dev 走 vite proxy
const DEFAULT_TIMEOUT_MS = 30_000 const DEFAULT_TIMEOUT_MS = 30_000
interface RequestOptions { interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
body?: unknown body?: unknown
signal?: AbortSignal signal?: AbortSignal
} }
+79
View File
@@ -15,3 +15,82 @@ export class ApiException extends Error {
this.body = body 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
}
+68
View File
@@ -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<Workspace[]>(`/api/v1/projects/${enc(projectSlug)}/workspaces/`),
create: (projectSlug: string, body: CreateWorkspaceBody) =>
request<Workspace>(`/api/v1/projects/${enc(projectSlug)}/workspaces/`, {
method: 'POST',
body,
}),
get: (wsID: string) => request<Workspace>(`/api/v1/workspaces/${enc(wsID)}`),
update: (wsID: string, body: UpdateWorkspaceBody) =>
request<Workspace>(`/api/v1/workspaces/${enc(wsID)}`, { method: 'PATCH', body }),
delete: (wsID: string) =>
request<void>(`/api/v1/workspaces/${enc(wsID)}`, { method: 'DELETE' }),
sync: (wsID: string) =>
request<Workspace>(`/api/v1/workspaces/${enc(wsID)}/sync`, { method: 'POST' }),
// ===== Credential =====
upsertCredential: (wsID: string, body: CredentialUpsertBody) =>
request<CredentialMeta>(`/api/v1/workspaces/${enc(wsID)}/credential`, {
method: 'PUT',
body,
}),
getCredential: (wsID: string) =>
request<CredentialMeta>(`/api/v1/workspaces/${enc(wsID)}/credential`),
// ===== Worktree =====
listWorktrees: (wsID: string) =>
request<Worktree[]>(`/api/v1/workspaces/${enc(wsID)}/worktrees`),
createWorktree: (wsID: string, body: CreateWorktreeBody) =>
request<Worktree>(`/api/v1/workspaces/${enc(wsID)}/worktrees`, { method: 'POST', body }),
deleteWorktree: (wtID: string) =>
request<void>(`/api/v1/worktrees/${enc(wtID)}`, { method: 'DELETE' }),
acquire: (wtID: string) =>
request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/acquire`, { method: 'POST' }),
release: (wtID: string) =>
request<Worktree>(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }),
// ===== Git ops =====
statusOnMain: (wsID: string) =>
request<FileStatus[]>(`/api/v1/workspaces/${enc(wsID)}/main/status`),
commitOnMain: (wsID: string, body: CommitBody) =>
request<CommitResp>(`/api/v1/workspaces/${enc(wsID)}/main/commit`, { method: 'POST', body }),
pushOnMain: (wsID: string) =>
request<void>(`/api/v1/workspaces/${enc(wsID)}/main/push`, { method: 'POST' }),
statusOnWorktree: (wtID: string) =>
request<FileStatus[]>(`/api/v1/worktrees/${enc(wtID)}/status`),
commitOnWorktree: (wtID: string, body: CommitBody) =>
request<CommitResp>(`/api/v1/worktrees/${enc(wtID)}/commit`, { method: 'POST', body }),
pushOnWorktree: (wtID: string) =>
request<void>(`/api/v1/worktrees/${enc(wtID)}/push`, { method: 'POST' }),
}
+174
View File
@@ -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<Workspace[]>([])
const current = ref<Workspace | null>(null)
const worktrees = ref<Worktree[]>([])
const credential = ref<CredentialMeta | null>(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<Workspace> {
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<FileStatus[]> {
return workspacesApi.statusOnMain(wsID)
}
async function statusOnWorktree(wtID: string): Promise<FileStatus[]> {
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,
}
})