diff --git a/apps/code/src/main/db/migrations/0005_youthful_scarlet_spider.sql b/apps/code/src/main/db/migrations/0005_youthful_scarlet_spider.sql new file mode 100644 index 000000000..a4f59743a --- /dev/null +++ b/apps/code/src/main/db/migrations/0005_youthful_scarlet_spider.sql @@ -0,0 +1 @@ +ALTER TABLE `workspaces` ADD `linked_branch` text; diff --git a/apps/code/src/main/db/migrations/meta/0005_snapshot.json b/apps/code/src/main/db/migrations/meta/0005_snapshot.json new file mode 100644 index 000000000..22e3e1018 --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,526 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b530fcd1-77cc-4df0-ad3c-148ee9d5c46b", + "prevId": "c5ddb764-2a46-47c0-82b7-59658c60d306", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 791d110c9..5ea0be65d 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1774891000000, "tag": "0004_auth_preferences", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1775755977659, + "tag": "0005_youthful_scarlet_spider", + "breakpoints": true } ] } diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/apps/code/src/main/db/repositories/workspace-repository.mock.ts index 354814937..804af19ba 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.mock.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.mock.ts @@ -41,6 +41,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { pinnedAt: null, lastViewedAt: null, lastActivityAt: null, + linkedBranch: null, createdAt: now, updatedAt: now, }; @@ -62,6 +63,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { workspaces.delete(id); } }, + updateLinkedBranch: () => {}, updatePinnedAt: () => {}, updateLastViewedAt: () => {}, updateLastActivityAt: () => {}, diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/apps/code/src/main/db/repositories/workspace-repository.ts index 433af37b5..d22079bee 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.ts @@ -26,6 +26,7 @@ export interface IWorkspaceRepository { updatePinnedAt(taskId: string, pinnedAt: string | null): void; updateLastViewedAt(taskId: string, lastViewedAt: string): void; updateLastActivityAt(taskId: string, lastActivityAt: string): void; + updateLinkedBranch(taskId: string, linkedBranch: string | null): void; updateMode(taskId: string, mode: WorkspaceMode): void; deleteAll(): void; } @@ -124,6 +125,14 @@ export class WorkspaceRepository implements IWorkspaceRepository { .run(); } + updateLinkedBranch(taskId: string, linkedBranch: string | null): void { + this.db + .update(workspaces) + .set({ linkedBranch, updatedAt: now() }) + .where(byTaskId(taskId)) + .run(); + } + updateMode(taskId: string, mode: WorkspaceMode): void { this.db .update(workspaces) diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 86ec6c432..8e4f14404 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -27,6 +27,7 @@ export const workspaces = sqliteTable( onDelete: "set null", }), mode: text({ enum: ["cloud", "local", "worktree"] }).notNull(), + linkedBranch: text(), pinnedAt: text(), lastViewedAt: text(), lastActivityAt: text(), diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts index 2040ef111..d770db847 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -19,6 +19,7 @@ export const workspaceInfoSchema = z.object({ mode: workspaceModeSchema, worktree: worktreeInfoSchema.nullable(), branchName: z.string().nullable(), + linkedBranch: z.string().nullable(), }); export const workspaceSchema = z.object({ @@ -30,6 +31,7 @@ export const workspaceSchema = z.object({ worktreeName: z.string().nullable(), branchName: z.string().nullable(), baseBranch: z.string().nullable(), + linkedBranch: z.string().nullable(), createdAt: z.string(), }); @@ -97,6 +99,20 @@ export const branchChangedPayload = z.object({ branchName: z.string().nullable(), }); +export const linkedBranchChangedPayload = z.object({ + taskId: z.string(), + branchName: z.string().nullable(), +}); + +export const linkBranchInput = z.object({ + taskId: z.string(), + branchName: z.string(), +}); + +export const unlinkBranchInput = z.object({ + taskId: z.string(), +}); + export const localBackgroundedPayload = z.object({ mainRepoPath: z.string(), localWorktreePath: z.string(), @@ -230,6 +246,11 @@ export type WorkspaceErrorPayload = z.infer; export type WorkspaceWarningPayload = z.infer; export type WorkspacePromotedPayload = z.infer; export type BranchChangedPayload = z.infer; +export type LinkedBranchChangedPayload = z.infer< + typeof linkedBranchChangedPayload +>; +export type LinkBranchInput = z.infer; +export type UnlinkBranchInput = z.infer; export type LocalBackgroundedPayload = z.infer; export type LocalForegroundedPayload = z.infer; export type IsLocalBackgroundedInput = z.infer; diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 0a6200aec..dd64795b4 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -34,6 +34,7 @@ import type { SuspensionService } from "../suspension/service.js"; import type { BranchChangedPayload, CreateWorkspaceInput, + LinkedBranchChangedPayload, Workspace, WorkspaceErrorPayload, WorkspaceInfo, @@ -100,6 +101,7 @@ export const WorkspaceServiceEvent = { Warning: "warning", Promoted: "promoted", BranchChanged: "branchChanged", + LinkedBranchChanged: "linkedBranchChanged", } as const; export interface WorkspaceServiceEvents { @@ -107,6 +109,7 @@ export interface WorkspaceServiceEvents { [WorkspaceServiceEvent.Warning]: WorkspaceWarningPayload; [WorkspaceServiceEvent.Promoted]: WorkspacePromotedPayload; [WorkspaceServiceEvent.BranchChanged]: BranchChangedPayload; + [WorkspaceServiceEvent.LinkedBranchChanged]: LinkedBranchChangedPayload; } @injectable() @@ -312,6 +315,24 @@ export class WorkspaceService extends TypedEventEmitter _branchName: string, ): void {} + public linkBranch(taskId: string, branchName: string): void { + this.workspaceRepo.updateLinkedBranch(taskId, branchName); + this.emit(WorkspaceServiceEvent.LinkedBranchChanged, { + taskId, + branchName, + }); + log.info("Linked branch to task", { taskId, branchName }); + } + + public unlinkBranch(taskId: string): void { + this.workspaceRepo.updateLinkedBranch(taskId, null); + this.emit(WorkspaceServiceEvent.LinkedBranchChanged, { + taskId, + branchName: null, + }); + log.info("Unlinked branch from task", { taskId }); + } + private async getLocalWorktreePathIfExists( mainRepoPath: string, ): Promise { @@ -392,6 +413,7 @@ export class WorkspaceService extends TypedEventEmitter mode, worktree: null, branchName: null, + linkedBranch: null, }; } @@ -433,6 +455,7 @@ export class WorkspaceService extends TypedEventEmitter mode, worktree: null, branchName: localBranch, + linkedBranch: null, }; } @@ -539,6 +562,7 @@ export class WorkspaceService extends TypedEventEmitter mode, worktree, branchName: worktree.branchName, + linkedBranch: null, }; } @@ -710,12 +734,15 @@ export class WorkspaceService extends TypedEventEmitter return null; } + const dbRow = this.workspaceRepo.findByTaskId(taskId); + if (association.mode === "cloud") { return { taskId, mode: "cloud", worktree: null, branchName: null, + linkedBranch: dbRow?.linkedBranch ?? null, }; } @@ -750,11 +777,16 @@ export class WorkspaceService extends TypedEventEmitter mode: association.mode, worktree: worktreeInfo, branchName, + linkedBranch: dbRow?.linkedBranch ?? null, }; } async getAllWorkspaces(): Promise> { const associations = this.getAllTaskAssociations(); + const dbRows = this.workspaceRepo.findAll(); + const linkedBranchByTaskId = new Map( + dbRows.map((row) => [row.taskId, row.linkedBranch ?? null]), + ); const workspaces: Record = {}; for (const assoc of associations) { @@ -768,6 +800,7 @@ export class WorkspaceService extends TypedEventEmitter worktreeName: null, branchName: null, baseBranch: null, + linkedBranch: linkedBranchByTaskId.get(assoc.taskId) ?? null, createdAt: new Date().toISOString(), }; continue; @@ -804,6 +837,7 @@ export class WorkspaceService extends TypedEventEmitter worktreeName, branchName, baseBranch: null, + linkedBranch: linkedBranchByTaskId.get(assoc.taskId) ?? null, createdAt: new Date().toISOString(), }; } diff --git a/apps/code/src/main/trpc/routers/workspace.ts b/apps/code/src/main/trpc/routers/workspace.ts index ad8fd08e8..892eba1f0 100644 --- a/apps/code/src/main/trpc/routers/workspace.ts +++ b/apps/code/src/main/trpc/routers/workspace.ts @@ -19,12 +19,14 @@ import { getWorktreeSizeOutput, getWorktreeTasksInput, getWorktreeTasksOutput, + linkBranchInput, listGitWorktreesInput, listGitWorktreesOutput, markActivityInput, markViewedInput, togglePinInput, togglePinOutput, + unlinkBranchInput, verifyWorkspaceInput, verifyWorkspaceOutput, } from "../../services/workspace/schemas"; @@ -181,8 +183,19 @@ export const workspaceRouter = router({ return result; }), + linkBranch: publicProcedure + .input(linkBranchInput) + .mutation(({ input }) => + getService().linkBranch(input.taskId, input.branchName), + ), + + unlinkBranch: publicProcedure + .input(unlinkBranchInput) + .mutation(({ input }) => getService().unlinkBranch(input.taskId)), + onError: subscribe(WorkspaceServiceEvent.Error), onWarning: subscribe(WorkspaceServiceEvent.Warning), onPromoted: subscribe(WorkspaceServiceEvent.Promoted), onBranchChanged: subscribe(WorkspaceServiceEvent.BranchChanged), + onLinkedBranchChanged: subscribe(WorkspaceServiceEvent.LinkedBranchChanged), }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index c4bac8ad1..e12f14b08 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -211,6 +211,7 @@ export class TaskCreationSaga extends Saga< worktreeName: workspaceInfo.worktree?.worktreeName ?? null, branchName: workspaceInfo.worktree?.branchName ?? null, baseBranch: workspaceInfo.worktree?.baseBranch ?? null, + linkedBranch: workspaceInfo.linkedBranch ?? null, createdAt: workspaceInfo.worktree?.createdAt ?? new Date().toISOString(), }; @@ -247,6 +248,7 @@ export class TaskCreationSaga extends Saga< worktreeName: null, branchName: null, baseBranch: branch, + linkedBranch: null, createdAt: new Date().toISOString(), }; }