diff --git a/.github/assets/workbench.png b/.github/assets/workbench.png new file mode 100644 index 0000000..816c8fd Binary files /dev/null and b/.github/assets/workbench.png differ diff --git a/1.png b/1.png deleted file mode 100644 index 4e9c0de..0000000 Binary files a/1.png and /dev/null differ diff --git a/2.png b/2.png deleted file mode 100644 index 5c4e785..0000000 Binary files a/2.png and /dev/null differ diff --git a/README.md b/README.md index 4d46855..6e736ff 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,24 @@ It gathers resources from your account or a selected repository, shows their GitHub state in a compact workbench, and can create or reopen a corresponding Paseo workspace for an issue or pull request. -## Screenshots +## Screenshot -### Account workbench +![GitHub Workbench two-pane interface](.github/assets/workbench.png) -![Account workbench showing GitHub pull requests and issues](1.png) +## Interface -### Project workbench +The responsive dark workbench uses a Codex-style two-pane layout: a left pane +lists GitHub issues and pull requests, while the right pane shows the selected +resource's metadata, description, and supported pull-request or issue details. +On wide layouts, an equal initial split is used, and you can drag the visible +divider to size the list between 30% and 70% of the available width. On compact +layouts, selecting a resource opens its detail view with a back control to +return to the list. -![Project workbench filtered to a selected repository](2.png) +Use the All, Issues, PRs, Mine, and Review tabs to change the list context. +The filter menu lets you filter by GitHub Status (Open, Merged, Closed), +GitHub Repository, workflow stage, and ownership relationship. The +detail action opens an existing Paseo workspace or creates one when needed. ## What it does diff --git a/README.zh-CN.md b/README.zh-CN.md index aa2b4be..1c1fccf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # Paseo GitHub Workbench -[English](README.md) +[English](README.md) | [简体中文](README.zh-CN.md) GitHub Workbench 是一个 [Paseo](https://github.com/getpaseo/paseo) 插件,让你无需离开 Paseo,即可处理 GitHub Issue 和拉取请求(Pull Request)。 @@ -8,14 +8,13 @@ GitHub Workbench 是一个 [Paseo](https://github.com/getpaseo/paseo) 插件, ## 截图 -### 账户工作台 +![GitHub Workbench 双栏工作台界面](.github/assets/workbench.png) -![显示 GitHub 拉取请求和 Issue 的账户工作台](1.png) +## 界面与布局 -### 项目工作台 - -![按所选仓库筛选的项目工作台](2.png) +响应式深色工作台采用类似 Codex 的双栏设计:左侧面板列出 GitHub Issue 和拉取请求,右侧面板展示所选资源的元数据、正文描述以及关联的详细信息。在宽屏布局下,默认采用 1:1 等分比例,并可通过拖动可见的分隔线在 30% 至 70% 之间调整列表宽度;在紧凑布局下,点击资源将直接打开详情面板,并提供返回列表的快捷操作。 +你可以通过顶部的“全部”、“Issue”、“PR”、“我的”和“评审”标签快速切换列表范围。筛选菜单支持按 GitHub 状态(未关闭、已合并、已关闭)、GitHub 仓库、工作流阶段及归属关系进行精确过滤。详情面板操作可一键打开对应的现有 Paseo 工作区,或按需新建工作区。 ## 功能 - 列出某个账户或仓库中处于打开状态的 GitHub Issue 和拉取请求。 diff --git a/index.ts b/index.ts index d60ed54..73d51b5 100644 --- a/index.ts +++ b/index.ts @@ -1,14 +1,12 @@ import type { PluginContext } from "@getpaseo/plugin"; import { GitHubWorkbenchSurface } from "./src/github-workbench.client"; import { - diagnosticsRpcHandler, ensureResourceWorkspace, listProjectCatalog, listResources, refreshResourceRpcHandler, } from "./src/github-workbench.server"; import { - diagnosticsRpc, ensureResourceWorkspaceRpc, listProjectCatalogRpc, listResourcesRpc, @@ -17,7 +15,6 @@ import { import { ProjectGitHubWorkbenchPanel } from "./src/project-workbench.client"; export default function contribute(plugin: PluginContext) { - plugin.handle(diagnosticsRpc, diagnosticsRpcHandler); plugin.handle(refreshResourceRpc, refreshResourceRpcHandler); plugin.handle(listProjectCatalogRpc, listProjectCatalog); plugin.handle(listResourcesRpc, listResources); diff --git a/src/github-resource-intake.server.test.ts b/src/github-resource-intake.server.test.ts index 83bc86e..e02f55c 100644 --- a/src/github-resource-intake.server.test.ts +++ b/src/github-resource-intake.server.test.ts @@ -14,6 +14,7 @@ describe("GitHubResourceIntake", () => { number: 42, title: "Test PR", url: "https://github.com/getpaseo/paseo/pull/42", + body: "PR list description", author: { login: "alice" }, headRefName: "feature-branch", baseRefName: "main", @@ -48,6 +49,7 @@ describe("GitHubResourceIntake", () => { { number: 99, title: "Test Issue", + body: "Issue list description", url: "https://github.com/getpaseo/paseo/issues/99", author: { login: "bob" }, assignees: { nodes: [] }, @@ -77,6 +79,13 @@ describe("GitHubResourceIntake", () => { expect(prCalls).toBe(1); expect(issueCalls).toBe(1); + expect( + first.resources.find((resource) => resource.kind === "pull-request") + ?.body, + ).toBe("PR list description"); + expect( + first.resources.find((resource) => resource.kind === "issue")?.body, + ).toBe("Issue list description"); expect(first.resources).toHaveLength(2); expect(second.resources).toHaveLength(2); expect(first.resources[0].key).toBe("pull-request:getpaseo/paseo#42"); @@ -133,6 +142,31 @@ describe("GitHubResourceIntake", () => { return { stdout: "octocat\n", stderr: "" }; } if (args[0] === "api" && args[1] === "graphql") { + const queryArg = + args.find((arg) => arg.startsWith("query=")) ?? + (args[args.indexOf("-f") + 1]?.startsWith("query=") + ? args[args.indexOf("-f") + 1] + : undefined); + if (!queryArg) { + throw new Error("Missing query argument in gh api graphql call"); + } + const rawQuery = queryArg.slice("query=".length); + + // Balanced braces helper + let braceDepth = 0; + let hadOpenBrace = false; + for (const char of rawQuery) { + if (char === "{") { + braceDepth++; + hadOpenBrace = true; + } else if (char === "}") { + braceDepth--; + expect(braceDepth).toBeGreaterThanOrEqual(0); + } + } + expect(hadOpenBrace).toBe(true); + expect(braceDepth).toBe(0); + return { stdout: JSON.stringify({ data: { @@ -221,7 +255,6 @@ describe("GitHubResourceIntake", () => { } throw new Error(`Unexpected command: ${args.join(" ")}`); }); - const result = await intake.listResources({ scope: "account" }); expect(result.resources).toHaveLength(2); const pr = result.resources.find((r) => r.kind === "pull-request"); @@ -281,6 +314,7 @@ describe("GitHubResourceIntake", () => { stdout: JSON.stringify({ number: 15, title: "Refreshed PR", + body: "Refreshed description", url: "https://github.com/owner/repo/pull/15", author: { login: "dev" }, headRefName: "feature", @@ -308,6 +342,7 @@ describe("GitHubResourceIntake", () => { number: 15, }); + expect(result.resource.body).toBe("Refreshed description"); expect(result.resource.title).toBe("Refreshed PR"); if (result.resource.kind === "pull-request") { expect(result.resource.reviewDecision).toBe("changes_requested"); @@ -338,53 +373,4 @@ describe("GitHubResourceIntake", () => { "The GitHub repository is unavailable or you do not have access.", ); }); - - it("runs diagnostics and classifies status correctly", async () => { - const intake = createGitHubResourceIntake(async (args) => { - if (args[0] === "api" && args[1] === "user") { - return { stdout: "alice\n", stderr: "" }; - } - if (args[0] === "api" && args[1] === "rate_limit") { - return { - stdout: JSON.stringify({ - limit: 5000, - remaining: 4950, - reset: 1770000000, - }), - stderr: "", - }; - } - throw new Error(`Unexpected command: ${args.join(" ")}`); - }); - - const diag = await intake.diagnostics({}); - expect(diag.status).toBe("ok"); - expect(diag.viewerLogin).toBe("alice"); - expect(diag.remaining).toBe(4950); - expect(diag.limit).toBe(5000); - expect(diag.resetAt).toBe(new Date(1770000000 * 1000).toISOString()); - }); - - it("handles auth-required and rate-limited diagnostic states", async () => { - const authIntake = createGitHubResourceIntake(async () => { - const error = new Error("auth login required") as Error & { - stderr: string; - }; - error.stderr = "not logged in to any GitHub hosts"; - throw error; - }); - const authDiag = await authIntake.diagnostics({}); - expect(authDiag.status).toBe("auth-required"); - expect(authDiag.viewerLogin).toBeNull(); - - const rateIntake = createGitHubResourceIntake(async () => { - const error = new Error("rate limit exceeded") as Error & { - stderr: string; - }; - error.stderr = "HTTP 403: API rate limit reached"; - throw error; - }); - const rateDiag = await rateIntake.diagnostics({}); - expect(rateDiag.status).toBe("rate-limited"); - }); }); diff --git a/src/github-resource-intake.server.ts b/src/github-resource-intake.server.ts index 3561ef1..f2645c0 100644 --- a/src/github-resource-intake.server.ts +++ b/src/github-resource-intake.server.ts @@ -2,7 +2,6 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import type { z } from "zod"; import { - type diagnosticsRpc, type GitHubResource, type IssueResource, type listResourcesRpc, @@ -23,14 +22,10 @@ export type GitHubResourceIntake = { refreshResource( input: z.infer, ): Promise>; - diagnostics( - input: z.infer, - ): Promise>; }; type Warning = z.infer; type CacheValue = z.infer; -type DiagnosticsValue = z.infer; const execFile = promisify(execFileCallback); const CACHE_TTL_MS = 30_000; @@ -39,10 +34,14 @@ const pullRequestJsonFields = [ "number", "title", "url", + "body", "author", "headRefName", "baseRefName", "isDraft", + "state", + "mergedAt", + "closedAt", "labels", "updatedAt", "createdAt", @@ -56,6 +55,7 @@ const issueJsonFields = [ "number", "title", "url", + "body", "author", "assignees", "labels", @@ -63,15 +63,16 @@ const issueJsonFields = [ "comments", "createdAt", "updatedAt", + "closedAt", "state", ].join(","); const accountQuery = ` query Workbench($authoredPr: String!, $reviewPr: String!, $authoredIssue: String!, $assignedIssue: String!) { - authoredPr: search(query: $authoredPr, type: ISSUE, first: 50) { nodes { ... on PullRequest { number title url createdAt updatedAt isDraft headRefName baseRefName mergeable reviewDecision comments { totalCount } author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } statusCheckRollup { state contexts(first: 100) { nodes { ... on CheckRun { name status conclusion } ... on StatusContext { context state } } } } } } } - reviewPr: search(query: $reviewPr, type: ISSUE, first: 50) { nodes { ... on PullRequest { number title url createdAt updatedAt isDraft headRefName baseRefName mergeable reviewDecision comments { totalCount } author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } statusCheckRollup { state contexts(first: 100) { nodes { ... on CheckRun { name status conclusion } ... on StatusContext { context state } } } } } } } - authoredIssue: search(query: $authoredIssue, type: ISSUE, first: 50) { nodes { ... on Issue { number title url createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } } } } - assignedIssue: search(query: $assignedIssue, type: ISSUE, first: 50) { nodes { ... on Issue { number title url createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } } } } + authoredPr: search(query: $authoredPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { number title body url state mergedAt closedAt createdAt updatedAt isDraft headRefName baseRefName mergeable reviewDecision comments { totalCount } author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } statusCheckRollup { state contexts(first: 100) { nodes { ... on CheckRun { name status conclusion } ... on StatusContext { context state } } } } } } } + reviewPr: search(query: $reviewPr, type: ISSUE, first: 100) { nodes { ... on PullRequest { number title body url state mergedAt closedAt createdAt updatedAt isDraft headRefName baseRefName mergeable reviewDecision comments { totalCount } author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } statusCheckRollup { state contexts(first: 100) { nodes { ... on CheckRun { name status conclusion } ... on StatusContext { context state } } } } } } } + authoredIssue: search(query: $authoredIssue, type: ISSUE, first: 100) { nodes { ... on Issue { number title body url state closedAt createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } } } } + assignedIssue: search(query: $assignedIssue, type: ISSUE, first: 100) { nodes { ... on Issue { number title body url state closedAt createdAt updatedAt author { login } repository { nameWithOwner } labels(first: 20) { nodes { name } } assignees(first: 20) { nodes { login } } milestone { title } comments { totalCount } } } } }`; function defaultCommandRunner( @@ -251,18 +252,34 @@ function makePullRequest( const number = asNumber(record.number); const url = asString(record.url); if (!repository || !number || !url) return null; + const rawState = asString(record.state)?.toUpperCase(); + const mergedAt = asString(record.mergedAt); + const closedAt = asString(record.closedAt); + const state: PullRequestResource["state"] = + rawState === "MERGED" || mergedAt !== null + ? "MERGED" + : rawState === "CLOSED" + ? "CLOSED" + : "OPEN"; + const lifecycleState: PullRequestResource["lifecycleState"] = + state === "MERGED" ? "merged" : state === "CLOSED" ? "closed" : "open"; return { key: resourceKey("pull-request", repository, number), kind: "pull-request", repository, number, title: asString(record.title) ?? `Pull request #${number}`, + body: asString(record.body) ?? "", url, authorLogin: loginFrom(record.author), assigneeLogins: stringsFromNodes(record.assignees, "login"), labels: labelsFrom(record.labels), createdAt: asString(record.createdAt) ?? new Date(0).toISOString(), updatedAt: asString(record.updatedAt) ?? new Date(0).toISOString(), + closedAt: closedAt ?? null, + mergedAt: mergedAt ?? null, + state, + lifecycleState, commentCount: asNumber(asRecord(record.comments)?.totalCount) ?? 0, isMine: flags.isMine, isAssignedToMe: false, @@ -298,18 +315,28 @@ function makeIssue( const number = asNumber(record.number); const url = asString(record.url); if (!repository || !number || !url) return null; + const rawState = asString(record.state)?.toUpperCase(); + const closedAt = asString(record.closedAt); + const state: IssueResource["state"] = + rawState === "CLOSED" || closedAt !== null ? "CLOSED" : "OPEN"; + const lifecycleState: IssueResource["lifecycleState"] = + state === "CLOSED" ? "closed" : "open"; return { key: resourceKey("issue", repository, number), kind: "issue", repository, number, title: asString(record.title) ?? `Issue #${number}`, + body: asString(record.body) ?? "", url, authorLogin: loginFrom(record.author), assigneeLogins: stringsFromNodes(record.assignees, "login"), labels: labelsFrom(record.labels), createdAt: asString(record.createdAt) ?? new Date(0).toISOString(), updatedAt: asString(record.updatedAt) ?? new Date(0).toISOString(), + closedAt: closedAt ?? null, + state, + lifecycleState, isMine: flags.isMine, isAssignedToMe: flags.isAssignedToMe, workspaceIds: [], @@ -355,9 +382,6 @@ export function createGitHubResourceIntake( const inFlight = new Map>(); const viewerLogins = new Map(); const viewerInFlight = new Map>(); - let diagnosticsCache: { value: DiagnosticsValue; expiresAt: number } | null = - null; - let diagnosticsInFlight: Promise | null = null; async function getViewerLogin(): Promise { const cached = viewerLogins.get("github.com"); @@ -385,33 +409,42 @@ export function createGitHubResourceIntake( async function repositoryResources( repository: string, + state: "open" | "merged" | "closed" = "open", ): Promise { - const [pullRequests, issues] = await Promise.all([ + const prState = + state === "merged" ? "merged" : state === "closed" ? "closed" : "open"; + const issueState = state === "closed" ? "closed" : "open"; + const queries: Array> = [ run([ "pr", "list", "--repo", repository, "--state", - "open", + prState, "--limit", "100", "--json", pullRequestJsonFields, ]), - run([ - "issue", - "list", - "--repo", - repository, - "--state", - "open", - "--limit", - "100", - "--json", - issueJsonFields, - ]), - ]); + ]; + if (state !== "merged") { + queries.push( + run([ + "issue", + "list", + "--repo", + repository, + "--state", + issueState, + "--limit", + "100", + "--json", + issueJsonFields, + ]), + ); + } + const [pullRequests, issues] = await Promise.all(queries); const parse = (text: string) => { const value: unknown = JSON.parse(text); return Array.isArray(value) @@ -425,39 +458,53 @@ export function createGitHubResourceIntake( ...record, repository: { nameWithOwner: repository }, }); - return mergeResources([ - ...parse(pullRequests.stdout).flatMap((record) => { - const item = makePullRequest(decorateRepository(record), { - isMine: false, - reviewRequestedFromMe: false, - }); - return item ? [item] : []; - }), - ...parse(issues.stdout).flatMap((record) => { - const item = makeIssue(decorateRepository(record), { - isMine: false, - isAssignedToMe: false, - }); - return item ? [item] : []; - }), - ]); + const prItems = parse(pullRequests.stdout).flatMap((record) => { + const item = makePullRequest(decorateRepository(record), { + isMine: false, + reviewRequestedFromMe: false, + }); + return item ? [item] : []; + }); + const issueItems = issues + ? parse(issues.stdout).flatMap((record) => { + const item = makeIssue(decorateRepository(record), { + isMine: false, + isAssignedToMe: false, + }); + return item ? [item] : []; + }) + : []; + return mergeResources([...prItems, ...issueItems]); } - async function accountResources(): Promise { + async function accountResources( + state: "open" | "merged" | "closed" = "open", + ): Promise { const viewer = await getViewerLogin(); + const prQualifier = + state === "open" + ? "is:open" + : state === "merged" + ? "is:merged" + : "is:closed -is:merged"; + const issueQualifier = state === "closed" ? "is:closed" : "is:open"; const { stdout } = await run([ "api", "graphql", "-f", `query=${accountQuery}`, "-f", - `authoredPr=is:pr is:open author:${viewer}`, + `authoredPr=is:pr ${prQualifier} author:${viewer}`, "-f", - `reviewPr=is:pr is:open review-requested:${viewer}`, + `reviewPr=is:pr ${prQualifier} review-requested:${viewer}`, "-f", - `authoredIssue=is:issue is:open author:${viewer}`, + state === "merged" + ? `authoredIssue=is:issue is:closed author:__none__` + : `authoredIssue=is:issue ${issueQualifier} author:${viewer}`, "-f", - `assignedIssue=is:issue is:open assignee:${viewer}`, + state === "merged" + ? `assignedIssue=is:issue is:closed assignee:__none__` + : `assignedIssue=is:issue ${issueQualifier} assignee:${viewer}`, ]); const root = asRecord(asRecord(JSON.parse(stdout))?.data); const nodes = (name: string) => { @@ -469,7 +516,7 @@ export function createGitHubResourceIntake( }) : []; }; - return mergeResources([ + const prItems = [ ...nodes("authoredPr").flatMap((record) => { const item = makePullRequest(record, { isMine: true, @@ -484,22 +531,35 @@ export function createGitHubResourceIntake( }); return item ? [item] : []; }), - ...nodes("authoredIssue").flatMap((record) => { - const item = makeIssue(record, { isMine: true, isAssignedToMe: false }); - return item ? [item] : []; - }), - ...nodes("assignedIssue").flatMap((record) => { - const item = makeIssue(record, { isMine: false, isAssignedToMe: true }); - return item ? [item] : []; - }), - ]); + ]; + const issueItems = + state === "merged" + ? [] + : [ + ...nodes("authoredIssue").flatMap((record) => { + const item = makeIssue(record, { + isMine: true, + isAssignedToMe: false, + }); + return item ? [item] : []; + }), + ...nodes("assignedIssue").flatMap((record) => { + const item = makeIssue(record, { + isMine: false, + isAssignedToMe: true, + }); + return item ? [item] : []; + }), + ]; + return mergeResources([...prItems, ...issueItems]); } async function listResources( input: z.infer, ): Promise { const repository = input.repository?.toLowerCase(); - const key = `${input.scope}:${repository ?? "account"}`; + const state = input.state ?? "open"; + const key = `${input.scope}:${repository ?? "account"}:${state}`; const cached = cache.get(key); if (!input.forceRefresh && cached && cached.expiresAt > Date.now()) return cached.value; @@ -509,9 +569,9 @@ export function createGitHubResourceIntake( try { const resources = input.scope === "account" - ? await accountResources() + ? await accountResources(state) : repository - ? await repositoryResources(repository) + ? await repositoryResources(repository, state) : []; const value = { resources, @@ -572,60 +632,8 @@ export function createGitHubResourceIntake( } } - async function diagnostics( - input: z.infer, - ): Promise { - if ( - !input.forceRefresh && - diagnosticsCache && - diagnosticsCache.expiresAt > Date.now() - ) - return diagnosticsCache.value; - if (!input.forceRefresh && diagnosticsInFlight) return diagnosticsInFlight; - diagnosticsInFlight = (async () => { - try { - const [{ stdout: user }, { stdout: rate }] = await Promise.all([ - run(["api", "user", "--jq", ".login"]), - run(["api", "rate_limit", "--jq", ".resources.core"]), - ]); - const parsed = asRecord(JSON.parse(rate)); - const reset = asNumber(parsed?.reset); - const value: DiagnosticsValue = { - viewerLogin: user.trim() || null, - remaining: asNumber(parsed?.remaining), - limit: asNumber(parsed?.limit), - resetAt: reset === null ? null : new Date(reset * 1000).toISOString(), - status: "ok", - message: null, - }; - diagnosticsCache = { value, expiresAt: Date.now() + CACHE_TTL_MS }; - return value; - } catch (error) { - const warning = errorWarning(error); - const status = - warning.code === "gh-not-authenticated" - ? "auth-required" - : warning.code === "github-rate-limited" - ? "rate-limited" - : "unavailable"; - return { - viewerLogin: null, - remaining: null, - limit: null, - resetAt: null, - status, - message: warning.message, - }; - } finally { - diagnosticsInFlight = null; - } - })(); - return diagnosticsInFlight; - } - return { listResources, refreshResource, - diagnostics, }; } diff --git a/src/github-workbench.client.tsx b/src/github-workbench.client.tsx index a8a2f8a..0a1a011 100644 --- a/src/github-workbench.client.tsx +++ b/src/github-workbench.client.tsx @@ -1,166 +1,9 @@ import type { PluginSurfaceProps } from "@getpaseo/plugin"; -import { useRpc } from "@getpaseo/plugin"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; -import { Pressable, ScrollView, Text, View } from "react-native"; -import { listProjectCatalogRpc } from "./github-workbench.shared"; -import { I18nProvider, useTranslation } from "./i18n/context"; -import { GitHubDiagnosticsStatus, Workbench } from "./workbench-ui.client"; +import { I18nProvider } from "./i18n/context"; +import { Workbench } from "./workbench-ui.client"; function GitHubWorkbenchSurfaceInner(props: PluginSurfaceProps) { - const { t } = useTranslation(); - const [tab, setTab] = useState<"account" | "projects">("account"); - const [projectId, setProjectId] = useState(null); - const listProjectCatalog = useRpc(listProjectCatalogRpc); - const catalog = useQuery({ - queryKey: ["github-workbench", props.host.id, "project-catalog"], - queryFn: () => listProjectCatalog({}), - staleTime: 30_000, - }); - const projects = catalog.data?.projects ?? []; - const selectedProject = - projects.find((project) => project.projectId === projectId) ?? projects[0]; - - return ( - - - - setTab("account")} - > - - {t("navigation.tabs.account")} - - - setTab("projects")} - > - - {t("navigation.tabs.projects")} - - - - - - {tab === "projects" ? ( - - - {props.host.label} - {" · "} - {selectedProject - ? `${selectedProject.displayName} · ${selectedProject.repository ?? "No GitHub remote"}` - : catalog.isLoading - ? t("navigation.loadingProjects") - : ""} - - - {projects.map((project) => { - const selected = selectedProject?.projectId === project.projectId; - return ( - setProjectId(project.projectId)} - style={{ - backgroundColor: selected - ? props.theme.colors.surface2 - : props.theme.colors.surface0, - borderColor: selected - ? props.theme.colors.accent - : props.theme.colors.border, - borderRadius: 8, - borderWidth: 1, - paddingHorizontal: 12, - paddingVertical: 8, - }} - > - - {project.displayName} - - - ); - })} - - - ) : null} - - - ); + return ; } export function GitHubWorkbenchSurface(props: PluginSurfaceProps) { diff --git a/src/github-workbench.server.ts b/src/github-workbench.server.ts index b2a218c..6782df3 100644 --- a/src/github-workbench.server.ts +++ b/src/github-workbench.server.ts @@ -2,7 +2,6 @@ import type { PluginHandlerContext } from "@getpaseo/plugin/server"; import type { z } from "zod"; import { createGitHubResourceIntake } from "./github-resource-intake.server"; import type { - diagnosticsRpc, ensureResourceWorkspaceRpc, listProjectCatalogRpc, listResourcesRpc, @@ -27,12 +26,6 @@ export async function listProjectCatalog( return { projects: await provisioner.listProjects(paseo) }; } -export async function diagnosticsRpcHandler( - input: z.infer, -): Promise> { - return intake.diagnostics(input); -} - export async function refreshResourceRpcHandler( input: z.infer, _context: PluginHandlerContext, diff --git a/src/github-workbench.shared.ts b/src/github-workbench.shared.ts index ec1a4bc..5ed3671 100644 --- a/src/github-workbench.shared.ts +++ b/src/github-workbench.shared.ts @@ -4,6 +4,8 @@ import { z } from "zod"; export const ResourceKindSchema = z.enum(["pull-request", "issue"]); export type ResourceKind = z.infer; +export const LifecycleStateSchema = z.enum(["open", "merged", "closed"]); +export type LifecycleState = z.infer; export const ChecksStatusSchema = z.enum([ "success", "pending", @@ -30,12 +32,16 @@ const ResourceBaseSchema = z.object({ repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/), number: z.number().int().positive(), title: z.string(), + body: z.string().default(""), url: z.string().url(), authorLogin: z.string().nullable(), assigneeLogins: z.array(z.string()), labels: z.array(z.string()), createdAt: z.string(), updatedAt: z.string(), + closedAt: z.string().nullable().default(null), + state: z.enum(["OPEN", "CLOSED", "MERGED"]).default("OPEN"), + lifecycleState: LifecycleStateSchema.default("open"), isMine: z.boolean(), isAssignedToMe: z.boolean(), workspaceIds: z.array(z.string()), @@ -54,6 +60,7 @@ export const PullRequestResourceSchema = ResourceBaseSchema.extend({ isDraft: z.boolean(), headRefName: z.string().nullable(), baseRefName: z.string().nullable(), + mergedAt: z.string().nullable().default(null), checksStatus: ChecksStatusSchema, checkDetails: z.array(PullRequestCheckSchema).default([]), commentCount: z.number().int().nonnegative(), @@ -100,6 +107,7 @@ export const listResourcesRpc = defineRpc({ .string() .regex(/^[^/\s]+\/[^/\s]+$/) .optional(), + state: LifecycleStateSchema.default("open").optional(), forceRefresh: z.boolean().optional(), }) .superRefine((value, context) => { @@ -128,19 +136,6 @@ export const refreshResourceRpc = defineRpc({ output: z.object({ resource: GitHubResourceSchema }), }); -export const diagnosticsRpc = defineRpc({ - name: "github-workbench.diagnostics", - input: z.object({ forceRefresh: z.boolean().optional() }), - output: z.object({ - viewerLogin: z.string().nullable(), - remaining: z.number().int().nonnegative().nullable(), - limit: z.number().int().nonnegative().nullable(), - resetAt: z.string().nullable(), - status: z.enum(["ok", "auth-required", "rate-limited", "unavailable"]), - message: z.string().nullable(), - }), -}); - export const ProjectCatalogItemSchema = z.object({ projectId: z.string(), displayName: z.string(), diff --git a/src/github-workbench.test.ts b/src/github-workbench.test.ts index d2bd77e..f8bd286 100644 --- a/src/github-workbench.test.ts +++ b/src/github-workbench.test.ts @@ -9,7 +9,10 @@ import { resourceKey, resourceMatchesWorkspace, } from "./github-workbench.shared"; -import { resourceAccessibilityLabel } from "./workbench-ui.client"; +import { + clampWorkbenchListWidth, + resourceAccessibilityLabel, +} from "./workbench-ui.client"; function pullRequest( overrides: Partial = {}, @@ -20,6 +23,7 @@ function pullRequest( repository: "getpaseo/paseo", number: 42, title: "Test pull request", + body: "Test body", url: "https://github.com/getpaseo/paseo/pull/42", authorLogin: "ada", assigneeLogins: [], @@ -34,6 +38,10 @@ function pullRequest( isDraft: false, headRefName: "feature", baseRefName: "main", + closedAt: null, + mergedAt: null, + state: "OPEN", + lifecycleState: "open", checksStatus: "success", checkDetails: [], commentCount: 0, @@ -44,6 +52,13 @@ function pullRequest( }; } +describe("workbench layout", () => { + test("clamps the resource list to 30–70% of the available width", () => { + expect(clampWorkbenchListWidth(1_000, 0)).toBe(300); + expect(clampWorkbenchListWidth(1_000, 500)).toBe(500); + expect(clampWorkbenchListWidth(1_000, 1_000)).toBe(700); + }); +}); describe("GitHub workbench shared primitives", () => { test("normalizes HTTPS and SSH GitHub remotes", () => { expect( diff --git a/src/i18n/i18n.test.ts b/src/i18n/i18n.test.ts index e4a8d41..f7d7f09 100644 --- a/src/i18n/i18n.test.ts +++ b/src/i18n/i18n.test.ts @@ -59,27 +59,18 @@ describe("i18n translations and fallback", () => { const tZh = createTranslator("zh-CN"); test("provides English translation for known keys", () => { - expect(tEn("diagnostics.statusHealthy")).toBe("Connected and ready"); - expect(tEn("navigation.tabs.account")).toBe("Account"); + expect(tEn("workbench.filterStatus")).toBe("Status"); expect(tEn("filters.kinds.pullRequest")).toBe("PRs"); expect(tEn("resource.actions.createWorkspace")).toBe("Create workspace"); }); test("provides Chinese translation for known keys", () => { - expect(tZh("diagnostics.statusHealthy")).toBe("连接正常,准备就绪"); - expect(tZh("navigation.tabs.account")).toBe("账户"); + expect(tZh("workbench.filterStatus")).toBe("GitHub 状态"); expect(tZh("filters.kinds.pullRequest")).toBe("PR"); expect(tZh("resource.actions.createWorkspace")).toBe("创建工作区"); }); test("supports interpolation in both languages", () => { - expect(tEn("navigation.projectsBanner", { host: "Localhost" })).toBe( - "Paseo projects on Localhost", - ); - expect(tZh("navigation.projectsBanner", { host: "Localhost" })).toBe( - "位于 Localhost 上的 Paseo 项目", - ); - expect( tEn("resource.errors.unableToOpenExternal", { repository: "repo", @@ -113,10 +104,6 @@ describe("i18n translations and fallback", () => { ); expect(tZh("summary.needsAttention", { count: 2 })).toBe("2 项待处理"); }); - test("localizes compact diagnostics recheck action", () => { - expect(tEn("diagnostics.recheck")).toBe("Re-check"); - expect(tZh("diagnostics.recheck")).toBe("重新检测"); - }); test("localizes direct workspace actions and outcomes", () => { expect(tEn("resource.actions.creatingWorkspace")).toBe( "Creating workspace…", @@ -155,16 +142,6 @@ describe("i18n translations and fallback", () => { expect(tEn("resource.badges.draft")).toBe("Draft"); expect(tZh("resource.badges.draft")).toBe("草稿"); }); - test("localizes compact diagnostics status", () => { - expect(tEn("diagnostics.statusHealthy")).toBe("Connected and ready"); - expect(tZh("diagnostics.statusHealthy")).toBe("连接正常,准备就绪"); - expect(tEn("diagnostics.statusNotAuthenticated")).toBe( - "GitHub CLI is not authenticated", - ); - expect(tZh("diagnostics.statusNotAuthenticated")).toBe( - "GitHub CLI 未认证登录", - ); - }); test("localizes PR checks breakdown details and conclusions", () => { expect(tEn("checksDetails.title")).toBe("Checks breakdown"); expect(tZh("checksDetails.title")).toBe("Checks 明细"); @@ -301,11 +278,6 @@ describe("i18n translations and fallback", () => { ); }); - test("provides compact status translations in Chinese", () => { - const translator = createTranslator("zh-CN"); - expect(translator("diagnostics.statusHealthy")).toBe("连接正常,准备就绪"); - }); - test("falls back to fallback text or key itself for unknown key", () => { expect(tEn("non.existent.key", undefined, "Custom Fallback")).toBe( "Custom Fallback", diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 765524a..31a2079 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -13,22 +13,17 @@ export const en: TranslationDictionary = { empty: "No matching open GitHub resources.", errorPrefix: "Error", unableToLoad: "Unable to load GitHub resources.", - }, - diagnostics: { - noViewer: "Not authenticated", - recheck: "Re-check", - statusHealthy: "Connected and ready", - statusNotAuthenticated: "GitHub CLI is not authenticated", - statusRateLimited: "GitHub API rate limit exceeded", - statusError: "GitHub connection issue", - }, - navigation: { - tabs: { - account: "Account", - projects: "Projects", - }, - projectsBanner: "Paseo projects on {{host}}", - loadingProjects: "Loading registered projects…", + selectResource: "Select an issue or pull request to see its details.", + backToList: "Back to list", + filters: "Filters", + filterStatus: "Status", + filterWorkflow: "Workflow", + filterOwnership: "Ownership", + filterRepository: "Repository", + resizeDivider: "Resize resource list", + expandList: "Expand resource list", + shrinkList: "Shrink resource list", + updated: "Updated {{date}}", }, summary: { total: "{{count}} total", @@ -45,6 +40,11 @@ export const en: TranslationDictionary = { directionDesc: "Desc", }, filters: { + status: { + open: "Open", + merged: "Merged", + closed: "Closed", + }, kinds: { all: "All", pullRequest: "PRs", @@ -80,6 +80,8 @@ export const en: TranslationDictionary = { assigned: "Assigned", draft: "Draft", open: "Open", + merged: "Merged", + closed: "Closed", noRelationship: "No relation", ciPassing: "CI passing", ciRunning: "CI running", @@ -98,6 +100,11 @@ export const en: TranslationDictionary = { agentSummary: "Agent: {{title}}", moreAgents: " +{{count}}", linkedReference: "Ref #{{number}}", + noDescription: "No description provided.", + assignees: "Assignees: {{assignees}}", + branches: "{{head}} → {{base}}", + review: "Review: {{decision}}", + mergeability: "Mergeability: {{status}}", }, relationships: { linkedResource: "Linked #{{number}}", diff --git a/src/i18n/resources/zh-CN.ts b/src/i18n/resources/zh-CN.ts index 16223ad..382c203 100644 --- a/src/i18n/resources/zh-CN.ts +++ b/src/i18n/resources/zh-CN.ts @@ -12,22 +12,17 @@ export const zhCN: TranslationDictionary = { empty: "没有匹配的开放 GitHub 资源。", errorPrefix: "错误", unableToLoad: "无法加载 GitHub 资源。", - }, - diagnostics: { - noViewer: "未认证登录", - recheck: "重新检测", - statusHealthy: "连接正常,准备就绪", - statusNotAuthenticated: "GitHub CLI 未认证登录", - statusRateLimited: "已超出 GitHub API 速率限制", - statusError: "GitHub 连接异常", - }, - navigation: { - tabs: { - account: "账户", - projects: "项目", - }, - projectsBanner: "位于 {{host}} 上的 Paseo 项目", - loadingProjects: "正在加载已注册项目…", + selectResource: "选择一个 Issue 或 Pull Request 以查看详情。", + backToList: "返回列表", + filters: "筛选", + filterStatus: "GitHub 状态", + filterWorkflow: "工作流", + filterOwnership: "归属", + filterRepository: "仓库", + resizeDivider: "调整资源列表大小", + expandList: "扩大资源列表", + shrinkList: "缩小资源列表", + updated: "更新于 {{date}}", }, summary: { total: "共 {{count}} 项", @@ -44,6 +39,11 @@ export const zhCN: TranslationDictionary = { directionDesc: "降序", }, filters: { + status: { + open: "未关闭", + merged: "已合并", + closed: "已关闭", + }, kinds: { all: "全部", pullRequest: "PR", @@ -79,6 +79,8 @@ export const zhCN: TranslationDictionary = { assigned: "已指派", draft: "草稿", open: "开放", + merged: "已合并", + closed: "已关闭", noRelationship: "无账户关联", ciPassing: "CI 通过", ciRunning: "CI 运行中", @@ -97,6 +99,11 @@ export const zhCN: TranslationDictionary = { agentSummary: "智能体: {{title}}", moreAgents: " +{{count}}", linkedReference: "引用 #{{number}}", + noDescription: "未提供描述。", + assignees: "受理人:{{assignees}}", + branches: "{{head}} → {{base}}", + review: "评审:{{decision}}", + mergeability: "可合并性:{{status}}", }, relationships: { linkedResource: "关联 #{{number}}", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 9a3e861..f8852a9 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -23,22 +23,17 @@ export type TranslationDictionary = { empty: string; errorPrefix: string; unableToLoad: string; - }; - diagnostics: { - noViewer: string; - recheck: string; - statusHealthy: string; - statusNotAuthenticated: string; - statusRateLimited: string; - statusError: string; - }; - navigation: { - tabs: { - account: string; - projects: string; - }; - projectsBanner: string; - loadingProjects: string; + selectResource: string; + backToList: string; + filters: string; + filterStatus: string; + filterWorkflow: string; + filterOwnership: string; + filterRepository: string; + resizeDivider: string; + expandList: string; + shrinkList: string; + updated: string; }; summary: { total: string; @@ -55,6 +50,11 @@ export type TranslationDictionary = { directionDesc: string; }; filters: { + status: { + open: string; + merged: string; + closed: string; + }; kinds: { all: string; pullRequest: string; @@ -90,6 +90,8 @@ export type TranslationDictionary = { assigned: string; draft: string; open: string; + merged: string; + closed: string; noRelationship: string; ciPassing: string; ciRunning: string; @@ -108,6 +110,11 @@ export type TranslationDictionary = { agentSummary: string; moreAgents: string; linkedReference: string; + noDescription: string; + assignees: string; + branches: string; + review: string; + mergeability: string; }; relationships: { linkedResource: string; diff --git a/src/project-workbench.client.tsx b/src/project-workbench.client.tsx index b1cc104..fdcd8b0 100644 --- a/src/project-workbench.client.tsx +++ b/src/project-workbench.client.tsx @@ -2,11 +2,7 @@ import { type PluginWorkspacePanelProps, useWorkspace } from "@getpaseo/plugin"; import { useEffect, useState } from "react"; import { Text, View } from "react-native"; import { I18nProvider, useTranslation } from "./i18n/context"; -import { - GitHubDiagnosticsStatus, - useProjectRepositories, - Workbench, -} from "./workbench-ui.client"; +import { useProjectRepositories, Workbench } from "./workbench-ui.client"; function ProjectGitHubWorkbenchPanelInner(props: PluginWorkspacePanelProps) { const { t } = useTranslation(); @@ -47,23 +43,9 @@ function ProjectGitHubWorkbenchPanelInner(props: PluginWorkspacePanelProps) { } return ( - - - ); diff --git a/src/resource-index.shared.test.ts b/src/resource-index.shared.test.ts index 965fc8e..ef9e122 100644 --- a/src/resource-index.shared.test.ts +++ b/src/resource-index.shared.test.ts @@ -12,6 +12,7 @@ describe("ResourceIndex", () => { repository: "getpaseo/paseo", number: 10, title: "Fix bug #20 in parser", + body: "Fix description", url: "https://github.com/getpaseo/paseo/pull/10", authorLogin: "alice", assigneeLogins: [], @@ -26,6 +27,10 @@ describe("ResourceIndex", () => { isDraft: false, headRefName: "fix-20", baseRefName: "main", + closedAt: null, + mergedAt: null, + state: "OPEN", + lifecycleState: "open", checksStatus: "success", checkDetails: [], commentCount: 3, @@ -40,6 +45,7 @@ describe("ResourceIndex", () => { repository: "getpaseo/paseo", number: 12, title: "WIP feature", + body: "Draft description", url: "https://github.com/getpaseo/paseo/pull/12", authorLogin: "alice", assigneeLogins: [], @@ -54,6 +60,10 @@ describe("ResourceIndex", () => { isDraft: true, headRefName: "wip", baseRefName: "main", + closedAt: null, + mergedAt: null, + state: "OPEN", + lifecycleState: "open", checksStatus: "none", checkDetails: [], commentCount: 0, @@ -68,6 +78,7 @@ describe("ResourceIndex", () => { repository: "getpaseo/paseo", number: 20, title: "Parser crashes on null", + body: "Issue description", url: "https://github.com/getpaseo/paseo/issues/20", authorLogin: "bob", assigneeLogins: ["charlie"], @@ -79,6 +90,9 @@ describe("ResourceIndex", () => { workspaceIds: [], workspaceNames: [], agents: [], + closedAt: null, + state: "OPEN", + lifecycleState: "open", milestoneTitle: "v1.0", commentCount: 5, }; @@ -89,6 +103,7 @@ describe("ResourceIndex", () => { repository: "other/repo", number: 5, title: "Docs update", + body: "Docs description", url: "https://github.com/other/repo/issues/5", authorLogin: "dave", assigneeLogins: [], @@ -100,6 +115,9 @@ describe("ResourceIndex", () => { workspaceIds: [], workspaceNames: [], agents: [], + closedAt: null, + state: "OPEN", + lifecycleState: "open", milestoneTitle: "v2.0", commentCount: 1, }; diff --git a/src/resource-index.shared.ts b/src/resource-index.shared.ts index e4ee0c8..50784b6 100644 --- a/src/resource-index.shared.ts +++ b/src/resource-index.shared.ts @@ -2,6 +2,7 @@ import type { AgentSummary, GitHubResource, IssueResource, + LifecycleState, PullRequestResource, ResourceKind, } from "./github-workbench.shared"; @@ -41,7 +42,14 @@ export type PaseoDirectorySnapshot = { }; export type ResourceClassification = { - bucket: "needs-attention" | "being-handled" | "waiting" | "ready" | "open"; + bucket: + | "needs-attention" + | "being-handled" + | "waiting" + | "ready" + | "open" + | "merged" + | "closed"; reason: string; }; @@ -85,6 +93,7 @@ export type ResourceIndex = { focusKey: string | null; quickFilter: QuickResourceFilter; kind: ResourceKind | "all"; + lifecycleState?: LifecycleState | "all"; bucket: ResourceClassification["bucket"] | "all"; label: string | null; milestone: MilestoneFilter; @@ -104,8 +113,9 @@ const RESOURCE_BUCKET_ORDER: Record = ready: 2, waiting: 3, open: 4, + merged: 5, + closed: 6, }; - function activeAgent( agents: readonly AgentSummary[], ): AgentSummary | undefined { @@ -136,6 +146,12 @@ function activeAgent( function classifyPullRequest( resource: PullRequestResource, ): ResourceClassification { + if (resource.lifecycleState === "merged") { + return { bucket: "merged", reason: "Merged PR" }; + } + if (resource.lifecycleState === "closed") { + return { bucket: "closed", reason: "Closed PR" }; + } const agent = activeAgent(resource.agents); if ( agent && @@ -189,6 +205,9 @@ function classifyPullRequest( } function classifyIssue(resource: IssueResource): ResourceClassification { + if (resource.lifecycleState === "closed") { + return { bucket: "closed", reason: "Closed issue" }; + } const agent = activeAgent(resource.agents); if ( agent && @@ -398,6 +417,7 @@ export function createResourceIndex( focusKey: string | null; quickFilter: QuickResourceFilter; kind: ResourceKind | "all"; + lifecycleState?: LifecycleState | "all"; bucket: ResourceClassification["bucket"] | "all"; label: string | null; milestone: MilestoneFilter; @@ -445,6 +465,12 @@ export function createResourceIndex( ) continue; if (criteria.kind !== "all" && res.kind !== criteria.kind) continue; + if ( + criteria.lifecycleState && + criteria.lifecycleState !== "all" && + res.lifecycleState !== criteria.lifecycleState + ) + continue; if ( criteria.bucket !== "all" && item.classification.bucket !== criteria.bucket diff --git a/src/workbench-ui.client.tsx b/src/workbench-ui.client.tsx index d9f96fe..d83bdc5 100644 --- a/src/workbench-ui.client.tsx +++ b/src/workbench-ui.client.tsx @@ -1,14 +1,8 @@ import type { PluginHostProps, PluginSurfaceProps } from "@getpaseo/plugin"; import { usePaseo, useRpc } from "@getpaseo/plugin"; import { useToast } from "@getpaseo/plugin/react-native"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - type ReactNode, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Linking, Pressable, @@ -16,92 +10,68 @@ import { Text, TextInput, View, - type ViewStyle, } from "react-native"; import { adjustPendingResourceCount, - diagnosticsRpc, ensureResourceWorkspaceRpc, type GitHubResource, + type LifecycleState, listResourcesRpc, mergeRefreshedResource, normalizeGitHubRepository, openExternalUrl, - type ResourceKind, refreshResourceRpc, } from "./github-workbench.shared"; -import type { Translator } from "./i18n"; import { useTranslation } from "./i18n/context"; import { - type AgentSnapshot, createResourceIndex, - type IndexedResource, - type MilestoneFilter, type PaseoDirectorySnapshot, - type QuickResourceFilter, type ResourceClassification, - type ResourceSortDimension, - type ResourceSortDirection, type WorkspaceSnapshot, } from "./resource-index.shared"; type ResourceScope = | { scope: "account" } | { scope: "repository"; repository: string }; - type WorkbenchProps = PluginSurfaceProps & { - scope: ResourceScope | null; - showDiagnostics?: boolean; -}; - -const REASON_KEY_MAP: Record = { - "Agent needs attention": "reasons.agentNeedsAttention", - "Agent failed": "reasons.agentFailed", - "Agent is working": "reasons.agentIsWorking", - "Checks running": "reasons.checksRunning", - "Your review requested": "reasons.yourReviewRequested", - "Checks failing": "reasons.checksFailing", - "Changes needed": "reasons.changesNeeded", - "Ready to merge": "reasons.readyToMerge", - "Waiting for GitHub activity": "reasons.waitingForActivity", - "Waiting for review": "reasons.waitingForReview", - "Waiting for mergeability": "reasons.waitingForMergeability", - "Assigned to you": "reasons.assignedToYou", - "Open issue": "reasons.openIssue", + scope: ResourceScope; }; - -export function localizeReason(reason: string, t: Translator): string { - const key = REASON_KEY_MAP[reason]; - return key ? t(key, undefined, reason) : reason; -} - -export function localizeChecksStatus(status: string, t: Translator): string { - const key = `checksStatus.${status}`; - return t(key, undefined, status); +type ContentTab = "all" | "issue" | "pull-request" | "mine" | "review"; +type OwnershipFilter = "all" | "mine" | "assigned" | "review"; +type StatusFilter = LifecycleState; +export function clampWorkbenchListWidth( + availableWidth: number, + requestedWidth: number, +) { + if (availableWidth <= 0) return 0; + return Math.min( + availableWidth * 0.7, + Math.max(availableWidth * 0.3, requestedWidth), + ); } -export function localizeReviewDecision( - decision: string, - t: Translator, -): string { - const camelKey = decision.replace(/_([a-z])/g, (_, letter) => - letter.toUpperCase(), - ); - const key = `reviewDecision.${camelKey}`; - return t(key, undefined, decision.replace(/_/g, " ")); +export function resourceAccessibilityLabel( + kind: string, + repository: string, + number: number, + title: string, +) { + return `${kind} ${repository} #${number}: ${title}`; } function usePaseoDirectory(hostId: string) { const paseo = usePaseo(); const queryClient = useQueryClient(); - const directoryQueryKey = useMemo( + const queryKey = useMemo( () => ["github-workbench", hostId, "directory"], [hostId], ); const query = useQuery({ - queryKey: directoryQueryKey, + queryKey, + staleTime: 0, queryFn: async () => { - const workspaces = [] as WorkspaceSnapshot[]; + const workspaces: WorkspaceSnapshot[] = []; + const agents: PaseoDirectorySnapshot["agents"] = []; let workspaceCursor: string | undefined; for (let page = 0; page < 10; page += 1) { const response = await paseo.workspaces.list({ @@ -126,7 +96,6 @@ function usePaseoDirectory(hostId: string) { workspaceCursor = response.pageInfo.nextCursor ?? undefined; if (!workspaceCursor) break; } - const agents = [] as AgentSnapshot[]; let agentCursor: string | undefined; for (let page = 0; page < 10; page += 1) { const response = await paseo.agents.list({ @@ -150,18 +119,15 @@ function usePaseoDirectory(hostId: string) { } return { workspaces, agents } satisfies PaseoDirectorySnapshot; }, - staleTime: 0, }); - useEffect(() => { let timer: ReturnType | undefined; const invalidate = () => { if (timer) clearTimeout(timer); - timer = setTimeout(() => { - queryClient.invalidateQueries({ - queryKey: directoryQueryKey, - }); - }, 500); + timer = setTimeout( + () => queryClient.invalidateQueries({ queryKey }), + 500, + ); }; const stopWorkspaces = paseo.workspaces.subscribe(invalidate); const stopAgents = paseo.agents.subscribe(invalidate); @@ -170,290 +136,282 @@ function usePaseoDirectory(hostId: string) { stopWorkspaces(); stopAgents(); }; - }, [directoryQueryKey, paseo, queryClient]); - + }, [paseo, queryClient, queryKey]); return query; } -function diagnosticsStatusText( - status: "ok" | "auth-required" | "rate-limited" | "unavailable" | undefined, - t: Translator, -) { - if (status === "ok") return t("diagnostics.statusHealthy"); - if (status === "auth-required") - return t("diagnostics.statusNotAuthenticated"); - if (status === "rate-limited") return t("diagnostics.statusRateLimited"); - return t("diagnostics.statusError"); -} -export function GitHubDiagnosticsStatus({ - hostId, +function StatusBadge({ + resource, theme, - compact, }: { - hostId: string; + resource: GitHubResource; theme: PluginHostProps["theme"]; - compact: boolean; }) { const { t } = useTranslation(); - const diagnostics = useRpc(diagnosticsRpc); - const queryClient = useQueryClient(); - const diagnosticsQuery = useQuery({ - queryKey: ["github-workbench", hostId, "diagnostics"], - queryFn: () => diagnostics({}), - staleTime: 30_000, - }); - const [rechecking, setRechecking] = useState(false); - const recheck = useCallback(async () => { - setRechecking(true); - try { - const value = await diagnostics({ forceRefresh: true }); - queryClient.setQueryData( - ["github-workbench", hostId, "diagnostics"], - value, - ); - } finally { - setRechecking(false); - } - }, [diagnostics, hostId, queryClient]); - const data = diagnosticsQuery.data; - const statusText = diagnosticsStatusText(data?.status, t); - const statusColor = - data?.status === "ok" - ? theme.colors.statusSuccess - : data?.status === "rate-limited" - ? theme.colors.statusWarning - : theme.colors.statusDanger; - - return ( - + if (resource.lifecycleState === "merged") { + return ( - - {data?.viewerLogin ? `@${data.viewerLogin}` : t("diagnostics.noViewer")} - - - {statusText} - - + {t("resource.badges.merged")} + + + ); + } + if (resource.lifecycleState === "closed") { + return ( + - {t("diagnostics.recheck")} + {t("resource.badges.closed")} - + + ); + } + const draft = resource.kind === "pull-request" && resource.isDraft; + const color = draft + ? theme.colors.foregroundMuted + : theme.colors.statusSuccess; + return ( + + + {draft ? t("resource.badges.draft") : t("resource.badges.open")} + ); } -function FilterChip({ - label, + +function ListRow({ + resource, selected, onPress, - styles, theme, }: { - label: string; + resource: GitHubResource; selected: boolean; onPress: () => void; - styles: { chip: ViewStyle; activeChip: ViewStyle }; theme: PluginHostProps["theme"]; }) { + const { t } = useTranslation(); + const kind = + resource.kind === "pull-request" + ? t("resource.kind.pullRequest") + : t("resource.kind.issue"); return ( + + + {kind} #{resource.number} + + + {resource.kind === "pull-request" && resource.reviewRequestedFromMe ? ( + + {t("resource.badges.review")} + + ) : null} + - {label} + {resource.title} + + + {resource.repository} ·{" "} + {t("resource.meta.commentsCount", { count: resource.commentCount })} + + + {t("workbench.updated", { + date: new Date(resource.updatedAt).toLocaleDateString(), + })} ); } -function ToolbarGroup({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - -export function resourceAccessibilityLabel( - kind: string, - repository: string, - number: number, - title: string, -): string { - return `${kind} ${repository} #${number}: ${title}`; -} - -function PullRequestChecks({ - resource, +function Body({ + body, theme, }: { - resource: Extract; + body: string; theme: PluginHostProps["theme"]; }) { const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); - const checks = [...resource.checkDetails].sort((left, right) => { - const rank = (status: string) => - status === "failure" ? 0 : status === "pending" ? 1 : 2; + if (!body.trim()) return ( - rank(left.status) - rank(right.status) || - left.name.localeCompare(right.name) + + {t("resource.meta.noDescription")} + ); - }); - const checkCount = checks.length; + const occurrences = new Map(); return ( - - setExpanded((value) => !value)} - > - - {expanded - ? t("checksDetails.hideDetails") - : t("checksDetails.showDetails", { count: checkCount })} - - - {expanded ? ( - checks.length === 0 ? ( - - {t("checksDetails.noDetails")} - - ) : ( - checks.map((check) => ( - + + {body + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => { + const occurrence = (occurrences.get(line) ?? 0) + 1; + occurrences.set(line, occurrence); + const key = `${line}-${occurrence}`; + const heading = /^(#{1,6})\s+(.+)$/.exec(line); + const bullet = /^\s*[-*+]\s+(.+)$/.exec(line); + const quote = /^>\s?(.*)$/.exec(line); + if (heading) + return ( 1 ? 7 : 0, }} > - {check.status === "failure" - ? t("checksDetails.failed") - : check.status === "pending" - ? t("checksDetails.pending") - : check.status === "unknown" - ? t("checksStatus.unknown") - : t("checksDetails.passed")} + {heading[2]} - - {check.name} + ); + if (bullet) + return ( + + • {bullet[1]} - - )) - ) - ) : null} + ); + if (quote) + return ( + + {quote[1]} + + ); + if (!line.trim()) return ; + return ( + + {line} + + ); + })} ); } -function ResourceRow({ - item, +function DetailPane({ + resource, theme, navigation, - compact, - onSelectLabel, - onSelectMilestone, - onFocusReference, + ensuring, + onEnsure, onRefresh, refreshing, - onEnsureWorkspace, - ensuringWorkspace, + onBack, }: { - item: IndexedResource; + resource: GitHubResource | null; theme: PluginHostProps["theme"]; navigation: WorkbenchProps["navigation"]; - compact: boolean; - onSelectLabel: (label: string) => void; - onSelectMilestone: (milestone: MilestoneFilter) => void; - onFocusReference: (resource: GitHubResource) => void; + ensuring: boolean; + onEnsure: (resource: GitHubResource) => void; onRefresh: (resource: GitHubResource) => void; refreshing: boolean; - onEnsureWorkspace: (resource: GitHubResource) => void; - ensuringWorkspace: boolean; + onBack?: () => void; }) { - const { resource, referencedTargets } = item; const { t } = useTranslation(); const toast = useToast(); - const primaryAgent = resource.agents[0]; - const openExternal = useCallback(async () => { - if (!(await openExternalUrl(resource.url, { linking: Linking }))) + const external = useCallback(async () => { + if ( + resource && + !(await openExternalUrl(resource.url, { linking: Linking })) + ) toast.error( t("resource.errors.unableToOpenExternal", { repository: resource.repository, @@ -461,556 +419,595 @@ function ResourceRow({ }), ); }, [resource, t, toast]); - - const kindLabel = + if (!resource) + return ( + + + {t("workbench.selectResource")} + + + ); + const kind = resource.kind === "pull-request" ? t("resource.kind.pullRequest") : t("resource.kind.issue"); - const relationshipBadges = [ - resource.isMine ? t("resource.badges.yours") : null, - resource.kind === "pull-request" && resource.reviewRequestedFromMe - ? t("resource.badges.review") - : null, - resource.isAssignedToMe ? t("resource.badges.assigned") : null, + const metadata = [ + `${kind} #${resource.number}`, + resource.authorLogin ? `@${resource.authorLogin}` : null, + t("resource.meta.commentsCount", { count: resource.commentCount }), + resource.kind === "issue" ? resource.milestoneTitle : resource.headRefName, ].filter(Boolean); - const resourcePill = { - backgroundColor: theme.colors.surface0, - borderColor: theme.colors.border, - borderRadius: 999, - borderWidth: 1, - paddingHorizontal: 8, - paddingVertical: 4, - } as const; return ( - + {onBack ? ( + + + ← {t("workbench.backToList")} + + + ) : null} - + + + + {resource.repository} + + + - {kindLabel} #{resource.number} + {resource.title} + + + {metadata.join(" · ")} - - {/* Dimension 1: Core Lifecycle */} - {resource.kind === "pull-request" && resource.isDraft ? ( - - - {t("resource.badges.draft")} - - - ) : ( - - - {t("resource.badges.open")} - - - )} - - {/* Dimension 2: CI Checks (PRs only) */} - {resource.kind === "pull-request" && - resource.checksStatus !== "none" ? ( - - - {resource.checksStatus === "success" - ? t("resource.badges.ciPassing") - : resource.checksStatus === "pending" - ? t("resource.badges.ciRunning") - : resource.checksStatus === "failure" - ? t("resource.badges.ciFailing") - : t("checksStatus.unknown")} - - - ) : null} - - {/* Dimension 3: Review Decision (PRs only) */} - {resource.kind === "pull-request" ? ( - resource.reviewDecision === "approved" ? ( - - - {t("resource.badges.approved")} - - - ) : resource.reviewDecision === "changes_requested" ? ( - - - {t("resource.badges.changesRequested")} - - - ) : resource.reviewDecision === "pending" ? ( - - - {t("resource.badges.reviewRequired")} - - - ) : ( - - - {t("resource.badges.unreviewed")} - - - ) - ) : null} - - {/* Dimension 4: Agent Status */} - {primaryAgent ? ( - + onRefresh(resource)} + style={{ + borderColor: theme.colors.border, + borderRadius: 6, + borderWidth: 1, + opacity: refreshing ? 0.55 : 1, + paddingHorizontal: 9, + paddingVertical: 6, + }} + > + + ↻ + + + + + ↗ + + + + + + {resource.labels.map((label) => ( + + + {label} + + + ))} + {resource.isMine ? ( + + {t("resource.badges.yours")} + + ) : null} + {resource.isAssignedToMe ? ( + + {t("resource.badges.assigned")} + + ) : null} + {resource.kind === "pull-request" && resource.reviewRequestedFromMe ? ( + + {t("resource.badges.review")} + + ) : null} + + {navigation?.openWorkspace ? ( + + onEnsure(resource)} + style={{ + backgroundColor: theme.colors.accent, + borderRadius: 7, + opacity: ensuring ? 0.55 : 1, + paddingHorizontal: 12, + paddingVertical: 8, + }} + > + 0 || - (primaryAgent.requiresAttention && - primaryAgent.attentionReason !== "finished") - ? theme.colors.statusDanger - : theme.colors.statusWarning, - borderRadius: 999, - borderWidth: 1, - paddingHorizontal: 8, - paddingVertical: 2, + color: theme.colors.accentForeground, + fontSize: 13, + fontWeight: "700", }} > + {ensuring + ? t("resource.actions.creatingWorkspace") + : resource.workspaceIds[0] + ? t("resource.actions.openWorkspace") + : t("resource.actions.createWorkspace")} + + + + ) : null} + + + {resource.kind === "pull-request" ? ( + + + {t("resource.meta.branches", { + head: resource.headRefName ?? "—", + base: resource.baseRefName ?? "—", + })} + + + {t("resource.meta.review", { + decision: + resource.reviewDecision === "approved" + ? t("reviewDecision.approved") + : resource.reviewDecision === "changes_requested" + ? t("reviewDecision.changesRequested") + : resource.reviewDecision === "pending" + ? t("reviewDecision.reviewRequired") + : t("reviewDecision.none"), + })} + + + {t("resource.meta.mergeability", { status: resource.mergeable })} + + + {t("checksDetails.title")} + + + {t(`checksStatus.${resource.checksStatus}`)} + + {resource.checkDetails.map((check) => ( + 0 || - (primaryAgent.requiresAttention && - primaryAgent.attentionReason !== "finished") + check.status === "failure" ? theme.colors.statusDanger - : theme.colors.statusWarning, - fontSize: 11, + : check.status === "pending" + ? theme.colors.statusWarning + : check.status === "success" + ? theme.colors.statusSuccess + : theme.colors.foregroundMuted, + fontSize: 12, fontWeight: "700", }} > - {primaryAgent.status === "error" || - primaryAgent.pendingPermissions > 0 || - (primaryAgent.requiresAttention && - primaryAgent.attentionReason !== "finished") - ? t("resource.badges.agentAttention") - : t("resource.badges.agentWorking")} + {t(`checksStatus.${check.status}`)} - - ) : null} - - {/* Dimension 5: Merge Conflicts */} - {resource.kind === "pull-request" && - resource.mergeable === "CONFLICTING" ? ( - - {t("resource.badges.conflicting")} + {check.name} + ))} + + ) : ( + + {resource.milestoneTitle ? ( + + {resource.milestoneTitle} + + ) : null} + {resource.assigneeLogins.length ? ( + + {t("resource.meta.assignees", { + assignees: resource.assigneeLogins + .map((login) => `@${login}`) + .join(", "), + })} + ) : null} - - {resource.repository} - - onRefresh(resource)} + )} + {resource.agents[0] && navigation ? ( + - + navigation.openAgent({ agentId: resource.agents[0].id }) + } + style={{ paddingHorizontal: 5, paddingVertical: 8 }} > - ↻ - - - - + {t("resource.actions.openAgent")} + + + + ) : null} + + ); +} +function FilterIcon({ color }: { color: string }) { + return ( + + - {resource.title} - + /> + + + ); +} + +function FilterPopover({ + theme, + status, + bucket, + ownership, + repository, + repositories, + showRepositoryFilter, + setStatus, + setBucket, + setOwnership, + setRepository, +}: { + theme: PluginHostProps["theme"]; + status: StatusFilter; + bucket: ResourceClassification["bucket"] | "all"; + ownership: OwnershipFilter; + repository: string | null; + repositories: readonly string[]; + showRepositoryFilter: boolean; + setStatus: (value: StatusFilter) => void; + setBucket: (value: ResourceClassification["bucket"] | "all") => void; + setOwnership: (value: OwnershipFilter) => void; + setRepository: (value: string | null) => void; +}) { + const { t } = useTranslation(); + const button = (selected: boolean) => ({ + backgroundColor: selected ? theme.colors.surface2 : theme.colors.surface0, + borderColor: theme.colors.border, + borderRadius: 99, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 4, + }); + const statusOptions: Array<{ key: StatusFilter; label: string }> = [ + { key: "open", label: t("filters.status.open") }, + { key: "merged", label: t("filters.status.merged") }, + { key: "closed", label: t("filters.status.closed") }, + ]; + const bucketOptions = [ + "all", + "needs-attention", + "being-handled", + "waiting", + "ready", + "open", + ] as const; + return ( + + - - {t("resource.meta.commentsCount", { count: resource.commentCount })} - - {resource.kind === "issue" && resource.milestoneTitle ? ( + {t("workbench.filterStatus")} + + + {statusOptions.map((opt) => ( { - const title = resource.milestoneTitle; - if (title) onSelectMilestone({ kind: "named", title }); - }} - style={resourcePill} + accessibilityLabel={opt.label} + accessibilityState={{ selected: status === opt.key }} + onPress={() => setStatus(opt.key)} + style={button(status === opt.key)} > - - {resource.milestoneTitle} + + {opt.label} - ) : null} - {relationshipBadges.map((badge) => ( - - - {badge} - - ))} - {resource.labels.map((label) => ( - onSelectLabel(label)} - style={resourcePill} + + {showRepositoryFilter && repositories.length > 1 ? ( + <> + - - {label} - - - ))} - {referencedTargets.map((target) => ( + {t("workbench.filterRepository")} + + + setRepository(null)} + style={button(repository === null)} + > + + {t("filters.kinds.all")} + + + {repositories.map((value) => ( + setRepository(value)} + style={button(repository === value)} + > + + {value} + + + ))} + + + ) : null} + + {t("workbench.filterWorkflow")} + + + {bucketOptions.map((value) => ( onFocusReference(target)} - style={resourcePill} + accessibilityLabel={ + value === "all" + ? t("filters.buckets.all") + : t( + `filters.buckets.${value === "needs-attention" ? "needsAttention" : value === "being-handled" ? "beingHandled" : value}`, + ) + } + accessibilityState={{ selected: bucket === value }} + onPress={() => setBucket(value)} + style={button(bucket === value)} > - - {t("resource.meta.linkedReference", { number: target.number })} + + {value === "all" + ? t("filters.buckets.all") + : t( + `filters.buckets.${value === "needs-attention" ? "needsAttention" : value === "being-handled" ? "beingHandled" : value}`, + )} ))} - {resource.kind === "pull-request" ? ( - - ) : null} - {primaryAgent ? ( - - {t("resource.meta.agentSummary", { - title: primaryAgent.title ?? primaryAgent.id, - })} - {resource.agents.length > 1 - ? t("resource.meta.moreAgents", { - count: resource.agents.length - 1, - }) - : ""} - - ) : null} - - onEnsureWorkspace(resource)} - style={{ - backgroundColor: theme.colors.accent, - borderRadius: 8, - opacity: ensuringWorkspace ? 0.6 : 1, - paddingHorizontal: 12, - paddingVertical: 8, - }} - > - - {ensuringWorkspace - ? t("resource.actions.creatingWorkspace") - : resource.workspaceIds[0] - ? t("resource.actions.openWorkspace") - : t("resource.actions.createWorkspace")} - - - - - {t("resource.actions.openOnGitHub")} - - - {primaryAgent && navigation ? ( + {t("workbench.filterOwnership")} + + + {(["all", "mine", "assigned", "review"] as const).map((value) => ( navigation.openAgent({ agentId: primaryAgent.id })} - style={{ - backgroundColor: theme.colors.surface0, - borderColor: theme.colors.border, - borderRadius: 8, - borderWidth: 1, - paddingHorizontal: 10, - paddingVertical: 7, - }} + accessibilityLabel={ + value === "all" + ? t("filters.kinds.all") + : value === "mine" + ? t("filters.mine") + : value === "assigned" + ? t("resource.badges.assigned") + : t("resource.badges.review") + } + accessibilityState={{ selected: ownership === value }} + onPress={() => setOwnership(value)} + style={button(ownership === value)} > - - {t("resource.actions.openAgent")} + + {value === "all" + ? t("filters.kinds.all") + : value === "mine" + ? t("filters.mine") + : value === "assigned" + ? t("resource.badges.assigned") + : t("resource.badges.review")} - ) : null} + ))} ); @@ -1022,630 +1019,617 @@ export function Workbench({ host, navigation, scope, - showDiagnostics = true, }: WorkbenchProps) { const { t } = useTranslation(); const listResources = useRpc(listResourcesRpc); + const refreshResource = useRpc(refreshResourceRpc); + const ensureResourceWorkspace = useRpc(ensureResourceWorkspaceRpc); const queryClient = useQueryClient(); - const [quickFilter, setQuickFilter] = useState(null); - const [resourceKind, setResourceKind] = useState("all"); - const [sort, setSort] = useState("updated"); - const [sortDirection, setSortDirection] = - useState("desc"); - const [bucket, setBucket] = useState("all"); - const [activeLabel, setActiveLabel] = useState(null); - const [milestone, setMilestone] = useState(null); + const toast = useToast(); + const [tab, setTab] = useState("all"); const [search, setSearch] = useState(""); - const [activeFocus, setActiveFocus] = useState(null); + const [filterOpen, setFilterOpen] = useState(false); + const [status, setStatus] = useState("open"); + const [bucket, setBucket] = useState< + ResourceClassification["bucket"] | "all" + >("all"); + const [ownership, setOwnership] = useState("all"); + const [repository, setRepository] = useState(null); + const [selectedKey, setSelectedKey] = useState(null); + const [refreshingKey, setRefreshingKey] = useState(null); + const [pendingCounts, setPendingCounts] = useState( + () => new Map(), + ); + const [listWidth, setListWidth] = useState(null); + const [containerWidth, setContainerWidth] = useState(0); + const dragStartWidth = useRef(0); + const dragStartPageX = useRef(0); const directory = usePaseoDirectory(host.id); + const queryKey = [ + "github-workbench", + host.id, + scope.scope, + scope.scope === "repository" ? scope.repository : null, + status, + ] as const; + const scopeKey = + scope.scope === "repository" ? `repository:${scope.repository}` : "account"; const query = useQuery({ - queryKey: [ - "github-workbench", - host.id, - scope?.scope, - scope?.scope === "repository" ? scope.repository : null, - ], + queryKey, + refetchInterval: 60_000, queryFn: () => listResources( - scope?.scope === "repository" - ? { scope: "repository", repository: scope.repository } - : { scope: "account" }, + scope.scope === "repository" + ? { scope: "repository", repository: scope.repository, state: status } + : { scope: "account", state: status }, ), - enabled: Boolean(scope), - refetchInterval: 60_000, }); const index = useMemo( () => createResourceIndex(query.data?.resources ?? [], directory.data), - [directory.data, query.data?.resources], - ); - const queryResult = useMemo( - () => - index.query({ - focusKey: activeFocus, - quickFilter, - kind: resourceKind, - bucket: bucket as ResourceClassification["bucket"] | "all", - label: activeLabel, - milestone, - search, - sort, - direction: sortDirection, - }), - [ - activeFocus, - activeLabel, - bucket, - index, - milestone, - quickFilter, - resourceKind, - search, - sort, - sortDirection, - ], - ); - const filtered = queryResult.items; - const summary = queryResult.summary; - const mineCount = index.stats.mineCount; - const draftsCount = index.stats.draftsCount; - const milestoneOptions = index.stats.milestoneOptions; - const clearFilters = useCallback(() => { - setQuickFilter(null); - setResourceKind("all"); - setBucket("all"); - setActiveLabel(null); - setMilestone(null); - setSearch(""); - }, []); - const [refreshingKey, setRefreshingKey] = useState(null); - const toast = useToast(); - const refreshResource = useRpc(refreshResourceRpc); - const ensureResourceWorkspace = useRpc(ensureResourceWorkspaceRpc); - const ensureWorkspaceMutation = useMutation({ - mutationFn: ensureResourceWorkspace, - }); - const [pendingWorkspaceCounts, setPendingWorkspaceCounts] = useState( - () => new Map(), + [directory.data, query.data?.resources], ); - const ensureWorkspace = useCallback( - (resource: GitHubResource) => { - setPendingWorkspaceCounts((counts) => - adjustPendingResourceCount(counts, resource.key, 1), - ); - ensureWorkspaceMutation.mutate( - { - kind: resource.kind, - repository: resource.repository, - number: resource.number, - title: resource.title, - }, - { - onSuccess: (result) => { - setPendingWorkspaceCounts((counts) => - adjustPendingResourceCount(counts, resource.key, -1), - ); - if ( - (result.action === "opened" || result.action === "created") && - result.workspaceId - ) { - navigation?.openWorkspace?.({ workspaceId: result.workspaceId }); - toast.show( - result.action === "created" - ? t("resource.toasts.workspaceCreated") - : t("resource.toasts.workspaceOpened"), - { variant: "success" }, - ); - queryClient.invalidateQueries({ - queryKey: ["github-workbench", host.id, "directory"], - }); - return; - } - toast.error( - t( - result.action === "local-project-not-found" - ? "resource.errors.localProjectNotFound" - : result.action === "base-branch-unavailable" - ? "resource.errors.baseBranchUnavailable" - : "resource.errors.ensureWorkspaceFailed", - ), - ); - }, - onError: (error) => { - setPendingWorkspaceCounts((counts) => - adjustPendingResourceCount(counts, resource.key, -1), - ); - toast.error( - error instanceof Error - ? error.message - : t("resource.errors.ensureWorkspaceFailed"), - ); - }, - }, - ); - }, - [ensureWorkspaceMutation, host.id, navigation, queryClient, t, toast], + const repositories = useMemo( + () => + [...new Set(index.resources.map((resource) => resource.repository))].sort( + (left, right) => left.localeCompare(right), + ), + [index.resources], ); - const refreshMutation = useMutation({ mutationFn: refreshResource }); - const refreshItem = useCallback( - (resource: GitHubResource) => { - setRefreshingKey(resource.key); - refreshMutation.mutate( - { - kind: resource.kind, - repository: resource.repository, - number: resource.number, - }, - { - onSuccess: ({ resource: refreshed }) => { - queryClient.setQueryData( - [ - "github-workbench", - host.id, - scope?.scope, - scope?.scope === "repository" ? scope.repository : null, - ], - (current: { resources?: GitHubResource[] } | undefined) => - current - ? { - ...current, - resources: current.resources?.map((item) => - item.key === refreshed.key - ? mergeRefreshedResource(item, refreshed) - : item, - ), - } - : current, - ); - setRefreshingKey(null); - toast.show( - t("resource.toasts.refreshedItem", { - repository: resource.repository, - number: resource.number, - }), - { variant: "success" }, - ); - }, - onError: () => { - setRefreshingKey(null); - toast.error(t("resource.toasts.refreshFailed")); - }, - }, - ); - }, - [host.id, queryClient, refreshMutation, scope, t, toast.error, toast.show], + useEffect(() => { + void scopeKey; + setRepository(null); + setSelectedKey(null); + }, [scopeKey]); + useEffect(() => { + if (repository && !repositories.includes(repository)) setRepository(null); + if ( + selectedKey && + !index.resources.some((resource) => resource.key === selectedKey) + ) { + setSelectedKey(null); + } + }, [index.resources, repository, repositories, selectedKey]); + const rows = useMemo( + () => + index + .query({ + focusKey: null, + quickFilter: null, + kind: + tab === "issue" + ? "issue" + : tab === "pull-request" || tab === "review" + ? "pull-request" + : "all", + lifecycleState: status, + bucket, + label: null, + milestone: null, + search, + sort: "updated", + direction: "desc", + }) + .items.filter( + ({ resource }) => + (!repository || resource.repository === repository) && + !((tab === "mine" || ownership === "mine") && !resource.isMine) && + !( + (tab === "review" || ownership === "review") && + !( + resource.kind === "pull-request" && + resource.reviewRequestedFromMe + ) + ) && + !(ownership === "assigned" && !resource.isAssignedToMe), + ), + [bucket, index, ownership, repository, search, status, tab], ); + const selected = + rows.find((item) => item.resource.key === selectedKey)?.resource ?? null; + useEffect(() => { + if ( + selectedKey && + !rows.some((item) => item.resource.key === selectedKey) + ) { + setSelectedKey(null); + } + }, [rows, selectedKey]); + const showingDetail = layout.compact && selected !== null; + const count = (contentTab: ContentTab) => + index.resources.filter( + (resource) => + contentTab === "all" || + (contentTab === "issue" && resource.kind === "issue") || + (contentTab === "pull-request" && resource.kind === "pull-request") || + (contentTab === "mine" && resource.isMine) || + (contentTab === "review" && + resource.kind === "pull-request" && + resource.reviewRequestedFromMe), + ).length; const refresh = useCallback(() => { - if (!scope) return; queryClient .fetchQuery({ - queryKey: [ - "github-workbench", - host.id, - scope.scope, - scope.scope === "repository" ? scope.repository : null, - "forced", - ], + queryKey: [...queryKey, "forced"], queryFn: () => listResources( scope.scope === "repository" ? { scope: "repository", repository: scope.repository, + state: status, forceRefresh: true, } - : { scope: "account", forceRefresh: true }, + : { scope: "account", state: status, forceRefresh: true }, ), }) .then(() => query.refetch()) .catch(() => undefined); - }, [host.id, listResources, query, queryClient, scope]); - const styles = useMemo( - () => ({ - screen: { - backgroundColor: theme.colors.surface0, - flex: 1, - gap: layout.compact ? 10 : 14, - padding: layout.compact ? 12 : 20, - }, - title: { - color: theme.colors.foreground, - fontSize: layout.compact ? 20 : 24, - fontWeight: "700" as const, - }, - muted: { color: theme.colors.foregroundMuted }, - panel: { - backgroundColor: theme.colors.surface1, - borderColor: theme.colors.border, - borderRadius: layout.compact ? 10 : 12, - borderWidth: 1, - gap: layout.compact ? 8 : 10, - padding: layout.compact ? 10 : 12, - }, - toolbarRow: { - alignItems: "center" as const, - flexDirection: "row" as const, - flexWrap: "wrap" as const, - gap: layout.compact ? 6 : 8, - }, - chip: { - borderColor: theme.colors.border, - borderRadius: 999, - borderWidth: 1, - paddingHorizontal: 9, - paddingVertical: layout.compact ? 5 : 6, - }, - activeChip: { - backgroundColor: theme.colors.accent, - borderColor: theme.colors.accent, - }, - input: { - backgroundColor: theme.colors.surface0, - borderColor: theme.colors.border, - borderWidth: 1, - borderRadius: 8, - color: theme.colors.foreground, - flex: 1, - minWidth: layout.compact ? 150 : 220, - padding: layout.compact ? 8 : 10, - }, - action: { - borderColor: theme.colors.border, - borderRadius: 8, - borderWidth: 1, - paddingHorizontal: 10, - paddingVertical: 7, - }, - primaryAction: { - backgroundColor: theme.colors.accent, - borderColor: theme.colors.accent, - }, - }), - [layout.compact, theme], - ); - - const kindOptions = useMemo< - Array<{ key: ResourceKind | "all"; label: string }> - >( - () => [ - { key: "all", label: t("filters.kinds.all") }, - { key: "pull-request", label: t("filters.kinds.pullRequest") }, - { key: "issue", label: t("filters.kinds.issue") }, - ], - [t], + }, [listResources, query, queryClient, queryKey, scope, status]); + const refreshItem = useCallback( + (resource: GitHubResource) => { + setRefreshingKey(resource.key); + refreshResource({ + kind: resource.kind, + repository: resource.repository, + number: resource.number, + }) + .then(({ resource: refreshed }) => { + queryClient.setQueryData( + queryKey, + (current: { resources?: GitHubResource[] } | undefined) => + current + ? { + ...current, + resources: current.resources?.map((item) => + item.key === refreshed.key + ? mergeRefreshedResource(item, refreshed) + : item, + ), + } + : current, + ); + setRefreshingKey(null); + }) + .catch(() => { + setRefreshingKey(null); + toast.error(t("resource.toasts.refreshFailed")); + }); + }, + [queryClient, queryKey, refreshResource, t, toast], ); - - const bucketOptions = useMemo< - Array<{ - key: "all" | ResourceClassification["bucket"]; - label: string; - }> - >( - () => [ - { key: "all", label: t("filters.buckets.all") }, - { key: "needs-attention", label: t("filters.buckets.needsAttention") }, - { key: "being-handled", label: t("filters.buckets.beingHandled") }, - { key: "waiting", label: t("filters.buckets.waiting") }, - { key: "ready", label: t("filters.buckets.ready") }, - { key: "open", label: t("filters.buckets.open") }, - ], - [t], + const ensure = useCallback( + (resource: GitHubResource) => { + setPendingCounts((counts) => + adjustPendingResourceCount(counts, resource.key, 1), + ); + ensureResourceWorkspace({ + kind: resource.kind, + repository: resource.repository, + number: resource.number, + title: resource.title, + }) + .then((result) => { + setPendingCounts((counts) => + adjustPendingResourceCount(counts, resource.key, -1), + ); + if ( + (result.action === "opened" || result.action === "created") && + result.workspaceId + ) { + navigation?.openWorkspace?.({ workspaceId: result.workspaceId }); + toast.show( + result.action === "created" + ? t("resource.toasts.workspaceCreated") + : t("resource.toasts.workspaceOpened"), + { variant: "success" }, + ); + queryClient.invalidateQueries({ + queryKey: ["github-workbench", host.id, "directory"], + }); + return; + } + toast.error( + t( + result.action === "local-project-not-found" + ? "resource.errors.localProjectNotFound" + : result.action === "base-branch-unavailable" + ? "resource.errors.baseBranchUnavailable" + : "resource.errors.ensureWorkspaceFailed", + ), + ); + }) + .catch((error) => { + setPendingCounts((counts) => + adjustPendingResourceCount(counts, resource.key, -1), + ); + toast.error( + error instanceof Error + ? error.message + : t("resource.errors.ensureWorkspaceFailed"), + ); + }); + }, + [ensureResourceWorkspace, host.id, navigation, queryClient, t, toast], ); - - if (!scope) - return ( - - {t("workbench.noScopeDescription")} - - ); - - const compactDiagnostics = showDiagnostics ? ( - - ) : null; - - return ( - - {compactDiagnostics ? ( - {compactDiagnostics} - ) : null} - {activeFocus ? ( - - = [ + { key: "all", label: t("filters.kinds.all") }, + { key: "issue", label: t("filters.kinds.issue") }, + { key: "pull-request", label: t("filters.kinds.pullRequest") }, + { key: "mine", label: t("filters.mine") }, + { key: "review", label: t("resource.badges.review") }, + ]; + const activeListWidth = + listWidth ?? clampWorkbenchListWidth(containerWidth, containerWidth * 0.5); + const list = ( + + + + - {t("resource.relationships.activeFocus", { - number: index.get(activeFocus)?.number ?? activeFocus, - })} - + {tabs.map(({ key, label }) => ( + setTab(key)} + style={{ + borderBottomColor: + tab === key ? theme.colors.accent : "transparent", + borderBottomWidth: 2, + paddingHorizontal: 7, + paddingVertical: 5, + }} + > + + {label} ({count(key)}) + + + ))} + setActiveFocus(null)} - style={[styles.action, styles.primaryAction]} + accessibilityLabel={t("workbench.refresh")} + onPress={refresh} + style={{ paddingHorizontal: 3, paddingVertical: 5 }} > - - {t("resource.relationships.clearFocus")} + + ↻ - ) : null} - - + - - - {t("workbench.refresh")} - - - - {kindOptions.map((item) => ( - setResourceKind(item.key)} - selected={resourceKind === item.key} - styles={styles} - theme={theme} - /> - ))} - - - {(["mine", "drafts"] as const).map((item) => { - const count = item === "mine" ? mineCount : draftsCount; - const label = - item === "mine" - ? t("filters.mineWithCount", { count }) - : t("filters.draftsWithCount", { count }); - return ( - - setQuickFilter(quickFilter === item ? null : item) - } - selected={quickFilter === item} - styles={styles} - theme={theme} - /> - ); - })} - - - - - {bucketOptions.map((item) => ( - setBucket(item.key)} - selected={bucket === item.key} - styles={styles} - theme={theme} - /> - ))} - - - {milestoneOptions.length > 0 ? ( - - setMilestone({ kind: "none" })} - selected={milestone?.kind === "none"} - styles={styles} - theme={theme} - /> - {milestoneOptions.map((item) => ( - setMilestone({ kind: "named", title: item })} - selected={ - milestone?.kind === "named" && milestone.title === item - } - styles={styles} - theme={theme} - /> - ))} - - ) : null} - {activeLabel ? ( - setActiveLabel(null)} - selected - styles={styles} - theme={theme} - /> - ) : null} - {milestone ? ( - setMilestone(null)} - selected - styles={styles} - theme={theme} - /> - ) : null} - - {(["updated", "priority", "created", "comments"] as const).map( - (item) => ( - setSort(item)} - selected={sort === item} - styles={styles} - theme={theme} - /> - ), - )} - - setSortDirection((value) => (value === "asc" ? "desc" : "asc")) - } - style={styles.action} - > - - {sortDirection === "asc" ? "↑" : "↓"}{" "} - {sortDirection === "asc" - ? t("sort.directionAsc") - : t("sort.directionDesc")} - - - - setFilterOpen((open) => !open)} style={{ alignItems: "center", - flexDirection: "row", - flexGrow: 1, - flexWrap: "wrap", - gap: 6, - justifyContent: "flex-end", + backgroundColor: filterOpen + ? theme.colors.surface2 + : theme.colors.surface1, + borderColor: theme.colors.border, + borderRadius: 7, + borderWidth: 1, + height: 36, + justifyContent: "center", + width: 36, }} > - + + {filterOpen ? ( + - {t("summary.total", { count: summary.total })} - - - {t("summary.pullRequests", { count: summary.pullRequests })} - - - {t("summary.issues", { count: summary.issues })} - - {summary.needsAttention ? ( - - {t("summary.needsAttention", { count: summary.needsAttention })} - - ) : null} - + + + ) : null} + + {t("summary.total", { count: rows.length })} + {query.data?.warnings.map((warning) => ( - + {warning.message} ))} {query.isLoading || directory.isLoading ? ( - + {t("workbench.loading")} ) : null} {query.error ? ( {query.error instanceof Error ? query.error.message : t("workbench.unableToLoad")} ) : null} - - {filtered.map((item) => ( - + {rows.map(({ resource }) => ( + setSelectedKey(resource.key)} theme={theme} - navigation={navigation} - compact={layout.compact} - onSelectLabel={setActiveLabel} - onSelectMilestone={setMilestone} - onFocusReference={(target) => setActiveFocus(target.key)} - onRefresh={refreshItem} - refreshing={refreshingKey === item.resource.key} - onEnsureWorkspace={ensureWorkspace} - ensuringWorkspace={ - (pendingWorkspaceCounts.get(item.resource.key) ?? 0) > 0 - } /> ))} - {!query.isLoading && filtered.length === 0 ? ( - - {t("workbench.empty")} - - - {t("filters.clearAll")} - - - + {!query.isLoading && rows.length === 0 ? ( + + {t("workbench.empty")} + ) : null} ); + return ( + + {showingDetail ? ( + 0 : false + } + onEnsure={ensure} + onBack={() => setSelectedKey(null)} + /> + ) : layout.compact ? ( + + {list} + + 0 : false + } + onEnsure={ensure} + /> + + + ) : ( + { + const availableWidth = Math.max(0, nativeEvent.layout.width - 10); + setContainerWidth(availableWidth); + setListWidth((current) => + clampWorkbenchListWidth( + availableWidth, + current ?? availableWidth * 0.5, + ), + ); + }} + style={{ flex: 1, flexDirection: "row" }} + > + {list} + { + const change = nativeEvent.actionName === "increment" ? 24 : -24; + setListWidth((current) => + clampWorkbenchListWidth( + containerWidth, + (current ?? containerWidth * 0.5) + change, + ), + ); + }} + onResponderGrant={({ nativeEvent }) => { + dragStartPageX.current = nativeEvent.pageX; + dragStartWidth.current = activeListWidth; + }} + onResponderMove={({ nativeEvent }) => { + setListWidth( + clampWorkbenchListWidth( + containerWidth, + dragStartWidth.current + + nativeEvent.pageX - + dragStartPageX.current, + ), + ); + }} + onStartShouldSetResponder={() => true} + onMoveShouldSetResponder={() => true} + style={{ + alignItems: "center", + justifyContent: "center", + cursor: "col-resize" as never, + width: 10, + }} + > + + + + 0 : false + } + onEnsure={ensure} + /> + + + )} + + ); } export function useProjectRepositories( projectId: string | null, hostId: string, ) { - const directory = usePaseoDirectory(hostId); - return useMemo( - () => [ - ...new Set( - (directory.data?.workspaces ?? []) - .filter( - (workspace) => - !workspace.archivingAt && - (projectId === null || workspace.projectId === projectId), - ) - .flatMap((workspace) => { - const repository = normalizeGitHubRepository(workspace.remoteUrl); - return repository ? [repository] : []; - }), - ), - ], - [directory.data?.workspaces, projectId], + const paseo = usePaseo(); + const queryClient = useQueryClient(); + const queryKey = useMemo( + () => ["github-workbench", hostId, "project-repositories", projectId], + [hostId, projectId], ); + const query = useQuery({ + queryKey, + enabled: Boolean(projectId), + queryFn: async () => { + if (!projectId) return []; + const repositories = new Set(); + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const response = await paseo.workspaces.list({ + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + for (const workspace of response.entries) { + if (workspace.projectId !== projectId || workspace.archivingAt) + continue; + const repository = normalizeGitHubRepository( + workspace.gitRuntime?.remoteUrl, + ); + if (repository) repositories.add(repository); + } + cursor = response.pageInfo.nextCursor ?? undefined; + if (!cursor) break; + } + return [...repositories].sort((left, right) => left.localeCompare(right)); + }, + }); + useEffect(() => { + const invalidate = () => queryClient.invalidateQueries({ queryKey }); + return paseo.workspaces.subscribe(invalidate); + }, [paseo, queryClient, queryKey]); + return query.data ?? []; }