From f79dc11703ddedaa00fd8c1ff1bf1767170f3c96 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Sun, 20 Sep 2026 19:12:13 +0530 Subject: [PATCH 01/14] feat(multi-repo): let a project own several repositories in one workspace A project keeps an ordered folder list (`project_folders`), and creating a workspace checks every folder out into one container, one worktree per folder, recorded in `workspace_repos`. Position 0 stays mirrored onto `projects.repo_path` and `workspaces.worktree_path`, so every single-repo surface keeps reading exactly the row it always did and a project with one repository behaves identically either way. `git.*` procedures take an optional `repo` naming a folder; omitting it means the primary. A backfill materializes the position-0 rows that every existing project and workspace implicitly had. Creation is all-or-nothing: the first failed folder rolls back the worktrees already added, deletes the branches the run minted, and removes the container, so a partial workspace can never reach the database. Deletion walks every checkout and only removes the container once all of them are gone. Behind the `multi-repo-projects` flag, which gates adding a second repository, not what the host can store. --- .../host-service/useGitStatus/useGitStatus.ts | 25 +- .../useHostWorkspaces.utils.test.ts | 1 + .../AddRepositoryModals.tsx | 8 + .../DashboardSidebarHeader.tsx | 11 + .../DashboardSidebarWorkspacesHeader.tsx | 13 + .../components/ShipControl/ShipControl.tsx | 22 +- .../hooks/usePRFlowState/usePRFlowState.ts | 4 +- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 2 + .../components/FilesTab/FilesTab.tsx | 11 +- .../RepoFolderPicker.test.tsx | 152 ++ .../RepoFolderPicker/RepoFolderPicker.tsx | 95 ++ .../hooks/useRepoDirtyCounts/index.ts | 1 + .../useRepoDirtyCounts/useRepoDirtyCounts.ts | 41 + .../components/RepoFolderPicker/index.ts | 1 + .../ChangesSection/ChangesSection.tsx | 12 +- .../ChangesTreeView/ChangesTreeView.tsx | 7 +- .../components/FileRow/FileRow.tsx | 8 +- .../useStagingMutations.ts | 24 +- .../hooks/useChangesTab/useChangesTab.tsx | 38 +- .../hooks/useChangeset/useChangeset.ts | 5 +- .../components/DiffPane/DiffPane.tsx | 19 +- .../DiffSidePreview/DiffSidePreview.tsx | 4 +- .../DiffHeaderMetadata/DiffHeaderMetadata.tsx | 10 +- .../useDiffCodeViewItems.ts | 14 +- .../createGetDiffInput/createGetDiffInput.ts | 4 + .../useReviewCommentNavigation.ts | 4 +- .../useSidebarDiffRef/useSidebarDiffRef.ts | 4 +- .../hooks/useWorkspaceRepos/index.ts | 6 + .../resolveSelectedRepo.test.ts | 25 + .../useWorkspaceRepos/resolveSelectedRepo.ts | 6 + .../useWorkspaceRepos.test.tsx | 124 ++ .../useWorkspaceRepos/useWorkspaceRepos.ts | 75 + .../WorkspaceGitStatusProvider.tsx | 4 +- .../state/selectedRepoStore/index.ts | 1 + .../selectedRepoStore/selectedRepoStore.ts | 18 + .../MultiRepoProjectModal.test.tsx | 110 ++ .../MultiRepoProjectModal.tsx | 180 +++ .../MultiRepoProjectModal.utils.ts | 41 + .../MultiRepoProjectForm.tsx | 132 ++ .../SelectedFolderRow/SelectedFolderRow.tsx | 63 + .../components/SelectedFolderRow/index.ts | 1 + .../components/MultiRepoProjectForm/index.ts | 1 + .../components/MultiRepoProjectModal/index.ts | 1 + .../V2ProjectSettings/V2ProjectSettings.tsx | 19 + .../SourceFoldersSection.test.tsx | 154 ++ .../SourceFoldersSection.tsx | 233 +++ .../SourceFoldersSection.utils.ts | 23 + .../RemoveFolderDialog/RemoveFolderDialog.tsx | 58 + .../components/RemoveFolderDialog/index.ts | 1 + .../RenameFolderDialog/RenameFolderDialog.tsx | 113 ++ .../components/RenameFolderDialog/index.ts | 1 + .../SourceFolderRow/SourceFolderRow.tsx | 89 ++ .../SourceFolderMenuItems.tsx | 79 + .../components/SourceFolderMenuItems/index.ts | 1 + .../components/SourceFolderRow/index.ts | 1 + .../components/SourceFoldersSection/index.ts | 1 + .../components/SourceFoldersSection/types.ts | 10 + .../renderer/stores/add-repository-modal.ts | 11 + .../useCloudWorkspaceItems.ts | 2 + docs/pages/multi-repo-competitive-scan.html | 945 +++++++++++ docs/pages/multi-repo-implementation.html | 996 ++++++++++++ .../workspaces/create/command.test.ts | 4 +- .../src/commands/workspaces/create/command.ts | 17 +- .../workspaces/create/createCloudWorkspace.ts | 4 +- .../src/commands/workspaces/get/command.ts | 17 +- .../drizzle/0036_sweet_king_bedlam.sql | 33 + .../drizzle/meta/0036_snapshot.json | 1396 +++++++++++++++++ .../host-service/drizzle/meta/_journal.json | 7 + packages/host-service/src/app.ts | 13 + packages/host-service/src/db/schema.ts | 84 + packages/host-service/src/events/types.ts | 3 + .../src/no-main-loop-blocking.test.ts | 1 + .../src/projects/project-folders.test.ts | 52 + .../src/projects/project-folders.ts | 152 ++ .../src/runtime/multi-repo-backfill.ts | 129 ++ .../host-service/src/trpc/router/git/git.ts | 186 ++- .../git/utils/git-status-refresh-limiter.ts | 74 +- .../git-status-store.adversarial.test.ts | 6 +- .../git-status-store/git-status-store.ts | 16 +- .../trpc/router/git/utils/resolve-worktree.ts | 51 +- .../trpc/router/project/folders/folders.ts | 304 ++++ .../src/trpc/router/project/folders/index.ts | 1 + .../src/trpc/router/project/project.ts | 2 + .../procedures/create-for-workspace.ts | 16 +- .../workspace-cleanup/workspace-cleanup.ts | 430 +++-- .../shared/base-ref-fetcher.ts | 26 + .../shared/branch-worktree.ts | 107 ++ .../multi-repo/create-worktrees.test.ts | 199 +++ .../shared/multi-repo/create-worktrees.ts | 193 +++ .../shared/multi-repo/index.ts | 12 + .../shared/multi-repo/resolve-folders.ts | 244 +++ .../shared/setup-terminal.ts | 15 +- .../src/trpc/router/workspace/workspace.ts | 11 + .../src/trpc/router/workspaces/workspaces.ts | 246 ++- .../src/workspaces/local-workspace-store.ts | 38 +- .../src/workspaces/workspace-repos.ts | 180 +++ .../multi-repo-workspace.integration.test.ts | 369 +++++ .../no-snapshot-fields-for-queries.test.ts | 4 + packages/shared/src/constants.ts | 5 + 99 files changed, 8246 insertions(+), 466 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/hooks/useRepoDirtyCounts/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/hooks/useRepoDirtyCounts/useRepoDirtyCounts.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/resolveSelectedRepo.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/resolveSelectedRepo.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/useWorkspaceRepos.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/useWorkspaceRepos.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/selectedRepoStore/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/selectedRepoStore/selectedRepoStore.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.utils.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/MultiRepoProjectForm.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/components/SelectedFolderRow/SelectedFolderRow.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/components/SelectedFolderRow/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.test.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.utils.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RemoveFolderDialog/RemoveFolderDialog.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RemoveFolderDialog/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RenameFolderDialog/RenameFolderDialog.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RenameFolderDialog/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/SourceFolderRow.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/SourceFolderMenuItems.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/types.ts create mode 100644 docs/pages/multi-repo-competitive-scan.html create mode 100644 docs/pages/multi-repo-implementation.html create mode 100644 packages/host-service/drizzle/0036_sweet_king_bedlam.sql create mode 100644 packages/host-service/drizzle/meta/0036_snapshot.json create mode 100644 packages/host-service/src/projects/project-folders.test.ts create mode 100644 packages/host-service/src/projects/project-folders.ts create mode 100644 packages/host-service/src/runtime/multi-repo-backfill.ts create mode 100644 packages/host-service/src/trpc/router/project/folders/folders.ts create mode 100644 packages/host-service/src/trpc/router/project/folders/index.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/base-ref-fetcher.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/branch-worktree.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/create-worktrees.test.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/create-worktrees.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/index.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/resolve-folders.ts create mode 100644 packages/host-service/src/workspaces/workspace-repos.ts create mode 100644 packages/host-service/test/integration/multi-repo-workspace.integration.test.ts diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts index 1b8f9d7e344..4211d44905e 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts @@ -11,6 +11,10 @@ const GIT_STATUS_STALE_TIME_MS = 5_000; // retaining large inactive workspaces for the global 30-minute default. export const GIT_STATUS_GC_TIME_MS = 10 * 60_000; +// Stable identity: an inline default would be a fresh object every render, +// re-creating the `git:changed` handler and churning its subscription. +const PRIMARY_REPO_ARG: { repo?: string } = {}; + /** * Fetches workspace git status and keeps it live against server events. * @@ -21,11 +25,15 @@ export const GIT_STATUS_GC_TIME_MS = 10 * 60_000; * `git:changed` is already debounced server-side in `GitWatcher` and covers * both `.git/` metadata writes and worktree file edits. */ -export function useGitStatus(workspaceId: string, enabled = true) { +export function useGitStatus( + workspaceId: string, + enabled = true, + repoArg: { repo?: string } = PRIMARY_REPO_ARG, +) { const utils = workspaceTrpc.useUtils(); const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( - { workspaceId }, + { workspaceId, ...repoArg }, { staleTime: Number.POSITIVE_INFINITY, enabled: enabled && Boolean(workspaceId), @@ -36,6 +44,7 @@ export function useGitStatus(workspaceId: string, enabled = true) { const query = workspaceTrpc.git.getStatus.useQuery( { workspaceId, + ...repoArg, baseBranch: baseBranch ?? undefined, priority: "foreground", }, @@ -68,23 +77,23 @@ export function useGitStatus(workspaceId: string, enabled = true) { // Patch query keys carry the changed-file list, not the working // tree, so an edit to an already-changed file leaves the cached // hunks stale while `loadDiffFiles` reads the file as it is now. - void utils.git.getDiffPatch.invalidate({ workspaceId }); + void utils.git.getDiffPatch.invalidate({ workspaceId, ...repoArg }); if (payload?.paths && payload.paths.length > 0) { for (const path of payload.paths) { - void utils.git.getDiff.invalidate({ workspaceId, path }); + void utils.git.getDiff.invalidate({ workspaceId, ...repoArg, path }); } } else { - void utils.git.getDiff.invalidate({ workspaceId }); + void utils.git.getDiff.invalidate({ workspaceId, ...repoArg }); // Current branch may have changed (external checkout), and // branch..base is per-branch — drop the cache so the next read // picks up the new branch's base. - void utils.git.getBaseBranch.invalidate({ workspaceId }); + void utils.git.getBaseBranch.invalidate({ workspaceId, ...repoArg }); // A metadata-only change can move HEAD (for example, an agent // committing outside the app), so refresh cached commit lists too. - void utils.git.listCommits.invalidate({ workspaceId }); + void utils.git.listCommits.invalidate({ workspaceId, ...repoArg }); } }, - [refreshScheduler, utils, workspaceId], + [refreshScheduler, utils, workspaceId, repoArg], ); useWorkspaceEvent("git:changed", workspaceId, invalidate, enabled); diff --git a/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.test.ts b/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.test.ts index 3449226a36c..104efd395f0 100644 --- a/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.test.ts +++ b/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.test.ts @@ -18,6 +18,7 @@ function makeSnapshot( branch: overrides.id, type: "worktree", worktreePath: `/tmp/${overrides.id}`, + rootPath: null, taskId: null, createdByUserId: null, createdAt: 1_700_000_000_000, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx index 75616a98d3d..02136038404 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx @@ -3,6 +3,7 @@ import { toast } from "@superset/ui/sonner"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useOpenNewWorkspaceForLocalProject } from "renderer/hooks/useOpenNewWorkspace"; import { EmptyProjectModal } from "renderer/routes/_authenticated/components/EmptyProjectModal"; +import { MultiRepoProjectModal } from "renderer/routes/_authenticated/components/MultiRepoProjectModal"; import { TemplateGalleryModal } from "renderer/routes/_authenticated/components/TemplateGalleryModal"; import { useAddRepositoryModalActive, @@ -55,6 +56,13 @@ export function AddRepositoryModals() { ) } /> + { + if (!open) close(); + }} + onSuccess={handleProjectCreated} + /> { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index ccba2220811..4ff4fc54426 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -24,6 +24,7 @@ import { LuSearch, } from "react-icons/lu"; import { + VscFolderLibrary, VscFolderOpened, VscGithubAlt, VscLayout, @@ -63,6 +64,7 @@ import { import { STROKE_WIDTH_THICK } from "renderer/screens/main/components/WorkspaceSidebar/constants"; import { useOpenEmptyProjectModal, + useOpenMultiRepoProjectModal, useOpenNewProjectModal, useOpenTemplateGalleryModal, } from "renderer/stores/add-repository-modal"; @@ -78,6 +80,7 @@ export function DashboardSidebarHeader({ const openNewWorkspace = useOpenNewWorkspace(); const openProjectWorkspace = useOpenNewWorkspaceForLocalProject(); const openEmptyProject = useOpenEmptyProjectModal(); + const openMultiRepoProject = useOpenMultiRepoProjectModal(); const openNewProject = useOpenNewProjectModal(); const openTemplateGallery = useOpenTemplateGalleryModal(); const navigate = useNavigate(); @@ -169,6 +172,8 @@ export function DashboardSidebarHeader({ const isPluginsEnabled = (useFeatureFlagEnabled(FEATURE_FLAGS.PLUGINS) ?? false) || env.NODE_ENV === "development"; + const isMultiRepoEnabled = + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? false; const { myFailedCount, hasAutomations, automationsPending } = useFailedAutomations(); @@ -532,6 +537,12 @@ export function DashboardSidebarHeader({ Create new project + {isMultiRepoEnabled && ( + openMultiRepoProject()}> + + Create project with multiple folders + + )} openTemplateGallery()}> Start from a template diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx index e68f61e02b8..50e19250cda 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx @@ -1,4 +1,5 @@ import { Trans, useLingui } from "@lingui/react/macro"; +import { FEATURE_FLAGS } from "@superset/shared/constants"; import { DropdownMenu, DropdownMenuContent, @@ -8,8 +9,10 @@ import { import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; +import { useFeatureFlagEnabled } from "posthog-js/react"; import { useEffect, useRef } from "react"; import { + VscFolderLibrary, VscFolderOpened, VscGithubAlt, VscLayout, @@ -20,6 +23,7 @@ import { useFolderFirstImport } from "renderer/routes/_authenticated/_dashboard/ import type { SidebarProjectSortMode } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import { useOpenEmptyProjectModal, + useOpenMultiRepoProjectModal, useOpenNewProjectModal, useOpenTemplateGalleryModal, } from "renderer/stores/add-repository-modal"; @@ -72,6 +76,9 @@ export function DashboardSidebarWorkspacesHeader({ wasSectionCollapsedRef.current = isSectionCollapsed; }, [isSectionCollapsed, onFilterQueryChange, setIsFilterExpanded]); const openEmptyProject = useOpenEmptyProjectModal(); + const openMultiRepoProject = useOpenMultiRepoProjectModal(); + const isMultiRepoEnabled = + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? false; const openNewWorkspace = useOpenNewWorkspaceForLocalProject(); const openNewProject = useOpenNewProjectModal(); const openTemplateGallery = useOpenTemplateGalleryModal(); @@ -177,6 +184,12 @@ export function DashboardSidebarWorkspacesHeader({ Create new project + {isMultiRepoEnabled && ( + openMultiRepoProject()}> + + Create project with multiple folders + + )} openTemplateGallery()}> Start from a template diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/ShipControl/ShipControl.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/ShipControl/ShipControl.tsx index 6cdeb1a8550..9c5e8afa27c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/ShipControl/ShipControl.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/components/ShipControl/ShipControl.tsx @@ -24,6 +24,7 @@ import { import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; import { usePullRequestPaneIntent } from "renderer/stores/pull-request-pane-intent"; +import { useWorkspaceRepos } from "../../../../hooks/useWorkspaceRepos"; import { useWorkspaceGitStatus } from "../../../../providers/WorkspaceGitStatusProvider"; import type { BranchSyncStatus } from "../../utils/getPRFlowState"; @@ -58,6 +59,7 @@ export function ShipControl({ const navigate = useNavigate(); const { workspace } = useWorkspace(); const status = useWorkspaceGitStatus(); + const { repoArg } = useWorkspaceRepos(workspaceId); const projectId = workspace.projectId; const canCreatePr = projectId != null; @@ -90,12 +92,13 @@ export function ShipControl({ // committing (here or in a terminal) enables it promptly; both queries // dedupe with the sidebar Changes tab's identical ones. const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( - { workspaceId }, + { workspaceId, ...repoArg }, { enabled: canCreatePr, staleTime: Number.POSITIVE_INFINITY }, ); const commitsQuery = workspaceTrpc.git.listCommits.useQuery( { workspaceId, + ...repoArg, baseBranch: baseBranchQuery.data?.baseBranch ?? undefined, }, { @@ -197,7 +200,7 @@ export function ShipControl({ const handleCommit = () => { const message = commitMessage.trim() || defaultCommitMessage; - commitMutation.mutate({ workspaceId, message }); + commitMutation.mutate({ workspaceId, ...repoArg, message }); }; const handleCreatePr = async () => { @@ -209,7 +212,7 @@ export function ShipControl({ // the push then would open the PR at the old remote tip. Pushing an // already-synced branch is a cheap no-op. try { - await flowPushMutation.mutateAsync({ workspaceId }); + await flowPushMutation.mutateAsync({ workspaceId, ...repoArg }); } catch (error) { toast.error( t({ @@ -228,6 +231,7 @@ export function ShipControl({ try { const created = await createPrMutation.mutateAsync({ workspaceId, + ...repoArg, title, body: prBody.trim() || undefined, draft: prDraft, @@ -353,7 +357,9 @@ export function ShipControl({ pushMutation.mutate({ workspaceId })} + onClick={() => + pushMutation.mutate({ workspaceId, ...repoArg }) + } > Push @@ -420,7 +426,9 @@ export function ShipControl({ type="button" className={mainButtonClass} disabled={pushMutation.isPending} - onClick={() => pushMutation.mutate({ workspaceId })} + onClick={() => + pushMutation.mutate({ workspaceId, ...repoArg }) + } > {pushMutation.isPending ? ( @@ -441,7 +449,9 @@ export function ShipControl({ pushMutation.mutate({ workspaceId })} + onClick={() => + pushMutation.mutate({ workspaceId, ...repoArg }) + } > Push diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/hooks/usePRFlowState/usePRFlowState.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/hooks/usePRFlowState/usePRFlowState.ts index cfea82c5e93..9c7387b6dfa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/hooks/usePRFlowState/usePRFlowState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/hooks/usePRFlowState/usePRFlowState.ts @@ -1,5 +1,6 @@ import { workspaceTrpc } from "@superset/workspace-client"; import { useMemo } from "react"; +import { useWorkspaceRepos } from "../../../../hooks/useWorkspaceRepos"; import { type PullRequest as FlowPullRequest, getPRFlowState, @@ -12,6 +13,7 @@ interface UsePRFlowStateResult { } export function usePRFlowState(workspaceId: string): UsePRFlowStateResult { + const { repoArg } = useWorkspaceRepos(workspaceId); const prQuery = workspaceTrpc.git.getPullRequest.useQuery( { workspaceId }, { @@ -23,7 +25,7 @@ export function usePRFlowState(workspaceId: string): UsePRFlowStateResult { ); const syncQuery = workspaceTrpc.git.getBranchSyncStatus.useQuery( - { workspaceId }, + { workspaceId, ...repoArg }, { enabled: !!workspaceId, refetchInterval: 10_000, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 19df7f85f3a..af133236293 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -18,6 +18,7 @@ import { } from "../../utils/setWorkspaceSidebarTab"; import { FilesTab } from "./components/FilesTab"; import { PRActionHeader } from "./components/PRActionHeader"; +import { RepoFolderPicker } from "./components/RepoFolderPicker"; import { SidebarHeader } from "./components/SidebarHeader"; import { type SelectedDiffTarget, useChangesTab } from "./hooks/useChangesTab"; import { useReviewTab } from "./hooks/useReviewTab"; @@ -176,6 +177,7 @@ export function WorkspaceSidebar({ className="isolate flex h-full w-full min-h-0 flex-col overflow-hidden bg-background" > + - {workspaceQuery.isLoading ? ( + {isWorkspaceLoading ? ( <> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.test.tsx new file mode 100644 index 00000000000..3f4bcb76ec4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.test.tsx @@ -0,0 +1,152 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +interface FakeRepo { + folder: string; + path: string; + projectId: string; + repository: string | null; + branch: string; + base: string | null; +} + +function repo(folder: string): FakeRepo { + return { + folder, + path: `/w/${folder}`, + projectId: `p-${folder}`, + repository: `acme/${folder}`, + branch: "feature", + base: "main", + }; +} + +interface FakeStatus { + againstBase: { path: string; additions: number; deletions: number }[]; + staged: never[]; + unstaged: never[]; +} + +let workspaceData: { worktreePath: string; repos: FakeRepo[] } = { + worktreePath: "/w/api", + repos: [repo("api")], +}; +let statusByFolder: Record = {}; + +mock.module("@superset/workspace-client", () => ({ + workspaceTrpc: { + workspace: { + get: { useQuery: () => ({ data: workspaceData, isLoading: false }) }, + }, + useQueries: ( + build: (t: { + git: { + getStatus: (input: { repo?: string }) => { data?: FakeStatus }; + }; + }) => { data?: FakeStatus }[], + ) => + build({ + git: { + getStatus: (input) => ({ + data: input.repo ? statusByFolder[input.repo] : undefined, + }), + }, + }), + }, +})); + +const { act, cleanup, fireEvent, render, within } = await import( + "@testing-library/react" +); +const { RepoFolderPicker } = await import("./RepoFolderPicker"); +const { useSelectedRepoStore } = await import( + "../../../../state/selectedRepoStore" +); + +beforeEach(() => { + useSelectedRepoStore.setState({ folders: {} }); + workspaceData = { worktreePath: "/w/api", repos: [repo("api")] }; + statusByFolder = {}; +}); +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +describe("RepoFolderPicker", () => { + test("renders nothing for a single-repo workspace", () => { + const { container } = render(); + + expect(container.innerHTML).toBe(""); + }); + + test("shows the primary folder for a multi-repo workspace", () => { + workspaceData = { + worktreePath: "/w/api", + repos: [repo("api"), repo("web")], + }; + render(); + + expect( + within(document.body).getByRole("button", { name: "Change folder" }) + .textContent, + ).toContain("api"); + }); + + test("choosing a folder in the menu selects it", async () => { + workspaceData = { + worktreePath: "/w/api", + repos: [repo("api"), repo("web")], + }; + render(); + const page = within(document.body); + + await act(async () => { + fireEvent.click(page.getByRole("button", { name: "Change folder" })); + }); + await act(async () => { + fireEvent.click(page.getByText("acme/web")); + }); + + expect(useSelectedRepoStore.getState().folders["ws-1"]).toBe("web"); + }); + + test("lists a per-folder changed-file count once status is known", async () => { + workspaceData = { + worktreePath: "/w/api", + repos: [repo("api"), repo("web")], + }; + statusByFolder = { + web: { + againstBase: [ + { path: "a.ts", additions: 1, deletions: 0 }, + { path: "b.ts", additions: 2, deletions: 1 }, + ], + staged: [], + unstaged: [], + }, + }; + render(); + const page = within(document.body); + + await act(async () => { + fireEvent.click(page.getByRole("button", { name: "Change folder" })); + }); + + expect(page.getByTitle("Changed files").textContent).toBe("2"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.tsx new file mode 100644 index 00000000000..9bd0d6914e1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.tsx @@ -0,0 +1,95 @@ +import { useLingui } from "@lingui/react/macro"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; +import { Check, ChevronDown, Folder } from "lucide-react"; +import { useState } from "react"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; +import { useRepoDirtyCounts } from "./hooks/useRepoDirtyCounts"; + +interface RepoFolderPickerProps { + workspaceId: string; +} + +export function RepoFolderPicker({ workspaceId }: RepoFolderPickerProps) { + const { t } = useLingui(); + const [open, setOpen] = useState(false); + const { repos, selected, hasMultipleRepos, selectFolder } = + useWorkspaceRepos(workspaceId); + const dirtyCounts = useRepoDirtyCounts({ + workspaceId, + repos, + enabled: open && hasMultipleRepos, + }); + + if (!hasMultipleRepos || !selected) return null; + + return ( +
+ + + + + + +
+ {repos.map((repo) => { + const isSelected = repo.folder === selected.folder; + const dirtyCount = dirtyCounts[repo.folder]; + return ( + + ); + })} +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/hooks/useRepoDirtyCounts/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/hooks/useRepoDirtyCounts/index.ts new file mode 100644 index 00000000000..2e7a48fe467 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/hooks/useRepoDirtyCounts/index.ts @@ -0,0 +1 @@ +export { useRepoDirtyCounts } from "./useRepoDirtyCounts"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/hooks/useRepoDirtyCounts/useRepoDirtyCounts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/hooks/useRepoDirtyCounts/useRepoDirtyCounts.ts new file mode 100644 index 00000000000..542e9c70dbf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/hooks/useRepoDirtyCounts/useRepoDirtyCounts.ts @@ -0,0 +1,41 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +import { useMemo } from "react"; +import { changesPillStats } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/changesPillStats"; +import type { WorkspaceRepo } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; + +const DIRTY_COUNT_STALE_TIME_MS = 30_000; + +const NO_REPOS: WorkspaceRepo[] = []; + +interface UseRepoDirtyCountsParams { + workspaceId: string; + repos: WorkspaceRepo[]; + enabled: boolean; +} + +/** Changed-file count per folder; a folder whose status is still in flight + * has no entry. */ +export function useRepoDirtyCounts({ + workspaceId, + repos, + enabled, +}: UseRepoDirtyCountsParams): Record { + const targets = enabled ? repos : NO_REPOS; + const statusQueries = workspaceTrpc.useQueries((t) => + targets.map((repo) => + t.git.getStatus( + { workspaceId, repo: repo.folder, priority: "background" }, + { staleTime: DIRTY_COUNT_STALE_TIME_MS }, + ), + ), + ); + + return useMemo(() => { + const counts: Record = {}; + targets.forEach((repo, index) => { + const data = statusQueries[index]?.data; + if (data) counts[repo.folder] = changesPillStats(data).fileCount; + }); + return counts; + }, [targets, statusQueries]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/index.ts new file mode 100644 index 00000000000..dc4b0bfc1df --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/index.ts @@ -0,0 +1 @@ +export { RepoFolderPicker } from "./RepoFolderPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx index 9ec49102980..15251ecbaf6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx @@ -13,6 +13,7 @@ import { ChevronRight, Minus, Plus } from "lucide-react"; import { type ReactNode, useState } from "react"; import { LuUndo2 } from "react-icons/lu"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; import { useV2ChangesSectionsStore, type V2ChangesSectionKey, @@ -49,14 +50,17 @@ export function ChangesSection({ const open = !collapsed; const [showConfirm, setShowConfirm] = useState(false); const utils = workspaceTrpc.useUtils(); + const { repoArg } = useWorkspaceRepos(stagingActions?.workspaceId ?? ""); const invalidate = () => { if (!stagingActions) return; void utils.git.getStatus.invalidate({ workspaceId: stagingActions.workspaceId, + ...repoArg, }); void utils.git.getDiff.invalidate({ workspaceId: stagingActions.workspaceId, + ...repoArg, }); }; @@ -117,9 +121,9 @@ export function ChangesSection({ if (!stagingActions) return; const { kind, workspaceId } = stagingActions; if (kind === "unstaged") { - discardAllUnstaged.mutate({ workspaceId }); + discardAllUnstaged.mutate({ workspaceId, ...repoArg }); } else { - discardAllStaged.mutate({ workspaceId }); + discardAllStaged.mutate({ workspaceId, ...repoArg }); } }; @@ -127,9 +131,9 @@ export function ChangesSection({ if (!stagingActions) return; const { kind, workspaceId } = stagingActions; if (kind === "unstaged") { - stageAll.mutate({ workspaceId }); + stageAll.mutate({ workspaceId, ...repoArg }); } else { - unstageAll.mutate({ workspaceId }); + unstageAll.mutate({ workspaceId, ...repoArg }); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index 48f83a91696..eb30b998e06 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -37,6 +37,7 @@ import { type ChangesetFile, getChangesetFileKey, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; import { toAbsoluteWorkspacePath, toRelativeWorkspacePath, @@ -340,10 +341,11 @@ export const ChangesTreeView = memo(function ChangesTreeView({ null, ); const utils = workspaceTrpc.useUtils(); + const { repoArg } = useWorkspaceRepos(workspaceId); const discardMutation = workspaceTrpc.git.discardChanges.useMutation({ onSuccess: () => { - void utils.git.getStatus.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); + void utils.git.getStatus.invalidate({ workspaceId, ...repoArg }); + void utils.git.getDiff.invalidate({ workspaceId, ...repoArg }); }, onError: (err) => { toast.error( @@ -508,6 +510,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ setDiscardTarget(null); discardMutation.mutate({ workspaceId, + ...repoArg, filePath: target.path, }); }} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx index 53eb1af3c01..0ed50b9e9cb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx @@ -43,6 +43,7 @@ import { type ChangesetFile, getChangesetFileKey, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; import { useFileDrag } from "../../hooks/useFileDrag"; import { useStagingMutations } from "../../hooks/useStagingMutations"; @@ -99,10 +100,11 @@ export const FileRow = memo(function FileRow({ const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); const isDeleteAction = file.status === "untracked" || file.status === "added"; const utils = workspaceTrpc.useUtils(); + const { repoArg } = useWorkspaceRepos(workspaceId); const discardMutation = workspaceTrpc.git.discardChanges.useMutation({ onSuccess: () => { - void utils.git.getStatus.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); + void utils.git.getStatus.invalidate({ workspaceId, ...repoArg }); + void utils.git.getDiff.invalidate({ workspaceId, ...repoArg }); }, onError: (err) => { toast.error( @@ -117,7 +119,7 @@ export const FileRow = memo(function FileRow({ }); const confirmDiscard = () => { setShowDiscardConfirm(false); - discardMutation.mutate({ workspaceId, filePath: file.path }); + discardMutation.mutate({ workspaceId, ...repoArg, filePath: file.path }); }; const { stageFile, unstageFile } = useStagingMutations(workspaceId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/hooks/useStagingMutations/useStagingMutations.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/hooks/useStagingMutations/useStagingMutations.ts index c653bdd309d..eeef72f80b7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/hooks/useStagingMutations/useStagingMutations.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/hooks/useStagingMutations/useStagingMutations.ts @@ -4,13 +4,15 @@ import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { useCallback } from "react"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; export function useStagingMutations(workspaceId: string) { const { t } = useLingui(); const utils = workspaceTrpc.useUtils(); + const { repoArg } = useWorkspaceRepos(workspaceId); const invalidate = () => { - void utils.git.getStatus.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); + void utils.git.getStatus.invalidate({ workspaceId, ...repoArg }); + void utils.git.getDiff.invalidate({ workspaceId, ...repoArg }); }; const { mutate: stage } = workspaceTrpc.git.stageFile.useMutation({ onSuccess: invalidate, @@ -31,13 +33,23 @@ export function useStagingMutations(workspaceId: string) { const stageFile = useCallback( (file: ChangesetFile) => - stage({ workspaceId, filePath: file.path, oldPath: file.oldPath }), - [stage, workspaceId], + stage({ + workspaceId, + ...repoArg, + filePath: file.path, + oldPath: file.oldPath, + }), + [stage, workspaceId, repoArg], ); const unstageFile = useCallback( (file: ChangesetFile) => - unstage({ workspaceId, filePath: file.path, oldPath: file.oldPath }), - [unstage, workspaceId], + unstage({ + workspaceId, + ...repoArg, + filePath: file.path, + oldPath: file.oldPath, + }), + [unstage, workspaceId, repoArg], ); return { stageFile, unstageFile }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index d5343122d1c..06e5b08c67b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -8,6 +8,7 @@ import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; import { useChangeset } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; import { useSidebarDiffRef } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; import { useWorkspaceGitStatus } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/WorkspaceGitStatusProvider"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; @@ -63,8 +64,10 @@ export function useChangesTab({ useV2UserPreferences(); const viewMode = preferences.changesViewMode; + const { repoArg, rootPath: worktreePath } = useWorkspaceRepos(workspaceId); + const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( - { workspaceId }, + { workspaceId, ...repoArg }, { staleTime: Number.POSITIVE_INFINITY }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; @@ -75,10 +78,6 @@ export function useChangesTab({ ref, }); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ - id: workspaceId, - }); - const worktreePath = workspaceQuery.data?.worktreePath; const openInExternalEditor = useOpenInExternalEditor(workspaceId); const handleOpenInEditor = useCallback( @@ -101,10 +100,10 @@ export function useChangesTab({ const setBaseBranchMutation = workspaceTrpc.git.setBaseBranch.useMutation({ onSuccess: () => { - void utils.git.getBaseBranch.invalidate({ workspaceId }); - void utils.git.getStatus.invalidate({ workspaceId }); - void utils.git.listCommits.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); + void utils.git.getBaseBranch.invalidate({ workspaceId, ...repoArg }); + void utils.git.getStatus.invalidate({ workspaceId, ...repoArg }); + void utils.git.listCommits.invalidate({ workspaceId, ...repoArg }); + void utils.git.getDiff.invalidate({ workspaceId, ...repoArg }); }, // The picker re-renders from getBaseBranch, so a rejected change // silently snaps back without this. @@ -119,18 +118,22 @@ export function useChangesTab({ const setBaseBranch = useCallback( (branchName: string | null) => { - setBaseBranchMutation.mutate({ workspaceId, baseBranch: branchName }); + setBaseBranchMutation.mutate({ + workspaceId, + ...repoArg, + baseBranch: branchName, + }); }, - [setBaseBranchMutation, workspaceId], + [setBaseBranchMutation, workspaceId, repoArg], ); const commits = workspaceTrpc.git.listCommits.useQuery( - { workspaceId, baseBranch: baseBranch ?? undefined }, + { workspaceId, ...repoArg, baseBranch: baseBranch ?? undefined }, { refetchOnWindowFocus: true }, ); const branches = workspaceTrpc.git.listBranches.useQuery( - { workspaceId }, + { workspaceId, ...repoArg }, { refetchInterval: 30_000, refetchOnWindowFocus: true }, ); @@ -143,6 +146,7 @@ export function useChangesTab({ toast.promise( renameBranchMutation.mutateAsync({ workspaceId, + ...repoArg, oldName: currentName, newName, }), @@ -163,7 +167,13 @@ export function useChangesTab({ }, ); }, - [workspaceId, status.data?.currentBranch.name, renameBranchMutation, t], + [ + workspaceId, + repoArg, + status.data?.currentBranch.name, + renameBranchMutation, + t, + ], ); const canRenameBranch = !status.data?.currentBranch.upstream; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts index 2876cd813a5..976e642a019 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts @@ -2,6 +2,7 @@ import { workspaceTrpc } from "@superset/workspace-client"; import { useMemo } from "react"; import type { FileStatus } from "../../components/StatusIndicator"; import { useWorkspaceGitStatus } from "../../providers/WorkspaceGitStatusProvider"; +import { useWorkspaceRepos } from "../useWorkspaceRepos"; import { buildChangesetFiles } from "./buildChangesetFiles"; import type { ChangesetFile, DiffRef } from "./types"; @@ -22,14 +23,16 @@ export function useChangeset({ ref, }: UseChangesetArgs): UseChangesetResult { const gitStatus = useWorkspaceGitStatus(); + const { repoArg } = useWorkspaceRepos(workspaceId); const commitQuery = workspaceTrpc.git.getCommitFiles.useQuery( ref.kind === "commit" ? { workspaceId, + ...repoArg, commitHash: ref.commitHash, fromHash: ref.fromHash, } - : { workspaceId, commitHash: "" }, + : { workspaceId, ...repoArg, commitHash: "" }, { enabled: ref.kind === "commit", staleTime: Number.POSITIVE_INFINITY, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx index 6aed475bf85..c53bc812ae1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx @@ -21,6 +21,7 @@ import { toast } from "@superset/ui/sonner"; import { useWorkspaceClient, workspaceTrpc } from "@superset/workspace-client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; import { createPaneScrollStateKey, getPaneScrollState, @@ -126,6 +127,7 @@ export function DiffPane({ const worktreePath = workspaceQuery.data?.worktreePath; const writeFile = workspaceTrpc.filesystem.writeFile.useMutation(); const utils = workspaceTrpc.useUtils(); + const { repoArg } = useWorkspaceRepos(workspaceId); const { trpcClient } = useWorkspaceClient(); // Binary previews of the index or HEAD side don't change their query key // when git state moves, so refetch them on git events. Scope to the @@ -137,11 +139,15 @@ export function DiffPane({ workspaceId, ({ paths }) => { if (!paths) { - void utils.git.readDiffSideFile.invalidate({ workspaceId }); + void utils.git.readDiffSideFile.invalidate({ workspaceId, ...repoArg }); return; } for (const path of paths) { - void utils.git.readDiffSideFile.invalidate({ workspaceId, path }); + void utils.git.readDiffSideFile.invalidate({ + workspaceId, + ...repoArg, + path, + }); } }, !!worktreePath, @@ -286,8 +292,8 @@ export function DiffPane({ next.delete(itemId); return next; }); - void utils.git.getStatus.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); + void utils.git.getStatus.invalidate({ workspaceId, ...repoArg }); + void utils.git.getDiff.invalidate({ workspaceId, ...repoArg }); return true; } catch (error) { toast.error( @@ -308,6 +314,7 @@ export function DiffPane({ workspaceId, utils, t, + repoArg, ], ); @@ -462,7 +469,7 @@ export function DiffPane({ files.find((candidate) => candidate.path === fileDiff.prevName); if (!file) throw new Error(`no changeset file for ${fileDiff.name}`); const { oldFile, newFile } = await trpcClient.git.getDiff.query( - createGetDiffInput(workspaceId, file), + createGetDiffInput(workspaceId, file, repoArg), ); if (isDiffContentTooLarge(oldFile.contents, newFile.contents)) { // Parsing this much text on the main thread is the freeze the @@ -485,7 +492,7 @@ export function DiffPane({ } return loaded; }, - [files, trpcClient, workspaceId], + [files, trpcClient, workspaceId, repoArg], ); const codeViewOptions = useMemo( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/BinaryDiffPreview/components/DiffSidePreview/DiffSidePreview.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/BinaryDiffPreview/components/DiffSidePreview/DiffSidePreview.tsx index 0b0c414c983..b4c4c35e354 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/BinaryDiffPreview/components/DiffSidePreview/DiffSidePreview.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/BinaryDiffPreview/components/DiffSidePreview/DiffSidePreview.tsx @@ -2,6 +2,7 @@ import { Trans } from "@lingui/react/macro"; import { workspaceTrpc } from "@superset/workspace-client"; import { type ReactNode, useMemo } from "react"; import { LuLoader } from "react-icons/lu"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; import { type ContentState, @@ -68,8 +69,9 @@ function GitObjectSide({ worktreePath, }: DiffSidePreviewProps) { const path = side === "old" ? (file.oldPath ?? file.path) : file.path; + const { repoArg } = useWorkspaceRepos(workspaceId); const query = workspaceTrpc.git.readDiffSideFile.useQuery( - { ...createGetDiffInput(workspaceId, file), path, side }, + { ...createGetDiffInput(workspaceId, file, repoArg), path, side }, { retry: false, staleTime: 30_000 }, ); const absolutePath = toAbsoluteWorkspacePath(worktreePath, path); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx index 491b8bee5b4..f7e3d7c9edb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx @@ -9,6 +9,7 @@ import { LuCheck, LuCopy, LuExternalLink, LuUndo2, LuX } from "react-icons/lu"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { useSidebarFilePolicy } from "renderer/lib/clickPolicy"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; import type { ChangesetFile } from "../../../../../useChangeset"; import { useDiffHeaderHover } from "../../hooks/useDiffHeaderHover"; @@ -88,10 +89,11 @@ export function DiffHeaderMetadata({ ); const utils = workspaceTrpc.useUtils(); + const { repoArg } = useWorkspaceRepos(workspaceId); const discardMutation = workspaceTrpc.git.discardChanges.useMutation({ onSuccess: () => { - void utils.git.getStatus.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); + void utils.git.getStatus.invalidate({ workspaceId, ...repoArg }); + void utils.git.getDiff.invalidate({ workspaceId, ...repoArg }); }, onError: (err) => { toast.error( @@ -112,8 +114,8 @@ export function DiffHeaderMetadata({ }, [canDiscard]); const confirmDiscard = useCallback(() => { setShowDiscardConfirm(false); - discardMutation.mutate({ workspaceId, filePath: file.path }); - }, [discardMutation, workspaceId, file.path]); + discardMutation.mutate({ workspaceId, ...repoArg, filePath: file.path }); + }, [discardMutation, workspaceId, file.path, repoArg]); const isDeleteAction = file.status === "untracked" || file.status === "added"; const basename = file.path.split("/").pop() ?? file.path; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts index f520117c90c..8feaca30409 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts @@ -13,6 +13,7 @@ import { getQueryKey } from "@trpc/react-query"; import type { inferRouterInputs } from "@trpc/server"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMissingProcedureError } from "renderer/lib/isMissingProcedureError"; +import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; import { type ChangesetFile, getChangesetFileKey, @@ -79,6 +80,7 @@ const FALLBACK_CONCURRENCY = 6; function groupKeyFor(input: GetDiffPatchInput): string { return [ + input.repo ?? "", input.category, input.baseBranch ?? "", input.commitHash ?? "", @@ -96,6 +98,7 @@ export function useDiffCodeViewItems({ extraAnnotationsByItemId, }: UseDiffCodeViewItemsOptions): UseDiffCodeViewItemsResult { const { trpcClient } = useWorkspaceClient(); + const { repoArg } = useWorkspaceRepos(workspaceId); // Generated artifacts (lockfiles, compiled catalogs) stay collapsed behind // a button: their patches are tens of thousands of hunk lines of noise, // and the compiled ones are single multi-megabyte lines, which @@ -152,7 +155,7 @@ export function useDiffCodeViewItems({ if (isGeneratedDiffFile(file.path) && !requestedItemIds.has(itemId)) { continue; } - const input = createGetDiffPatchInput(workspaceId, file); + const input = createGetDiffPatchInput(workspaceId, file, repoArg); const key = groupKeyFor(input); let group = groups.get(key); if (!group) { @@ -173,7 +176,7 @@ export function useDiffCodeViewItems({ group.members.push({ file, itemId }); } return [...groups.values()]; - }, [files, requestedItemIds, workspaceId]); + }, [files, requestedItemIds, workspaceId, repoArg]); const patchQueries = useQueries({ queries: patchGroups.map((group) => ({ @@ -203,7 +206,7 @@ export function useDiffCodeViewItems({ const member = members.shift(); if (!member) return; const { oldFile, newFile } = await trpcClient.git.getDiff.query( - createGetDiffInput(workspaceId, member.file), + createGetDiffInput(workspaceId, member.file, repoArg), ); files.push({ path: member.file.path, @@ -449,11 +452,13 @@ function buildPlaceholderItem( function createGetDiffPatchInput( workspaceId: string, file: ChangesetFile, + repoArg: { repo?: string }, ): GetDiffPatchInput { const { source } = file; if (source.kind === "against-base") { return { workspaceId, + ...repoArg, category: "against-base", baseBranch: source.baseBranch ?? undefined, }; @@ -461,12 +466,13 @@ function createGetDiffPatchInput( if (source.kind === "commit") { return { workspaceId, + ...repoArg, category: "commit", commitHash: source.commitHash, fromHash: source.fromHash, }; } - return { workspaceId, category: source.kind }; + return { workspaceId, ...repoArg, category: source.kind }; } function getDiffItemId(file: ChangesetFile): string { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/createGetDiffInput/createGetDiffInput.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/createGetDiffInput/createGetDiffInput.ts index 361dc311bd1..0e483e29a51 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/createGetDiffInput/createGetDiffInput.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/createGetDiffInput/createGetDiffInput.ts @@ -10,11 +10,13 @@ type GetDiffInput = inferRouterInputs["git"]["getDiff"]; export function createGetDiffInput( workspaceId: string, file: ChangesetFile, + repoArg: { repo?: string } = {}, ): GetDiffInput { const { source } = file; if (source.kind === "against-base") { return { workspaceId, + ...repoArg, path: file.path, category: "against-base", baseBranch: source.baseBranch ?? undefined, @@ -23,6 +25,7 @@ export function createGetDiffInput( if (source.kind === "commit") { return { workspaceId, + ...repoArg, path: file.path, category: "commit", commitHash: source.commitHash, @@ -31,6 +34,7 @@ export function createGetDiffInput( } return { workspaceId, + ...repoArg, path: file.path, category: source.kind, }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useReviewCommentNavigation/useReviewCommentNavigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useReviewCommentNavigation/useReviewCommentNavigation.ts index 6e690d20709..1a610d5fe75 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useReviewCommentNavigation/useReviewCommentNavigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useReviewCommentNavigation/useReviewCommentNavigation.ts @@ -2,6 +2,7 @@ import { workspaceTrpc } from "@superset/workspace-client"; import { useSettings } from "renderer/stores/settings"; import type { DiffFocusSide } from "../../types"; import { getChangesetFileKey } from "../useChangeset"; +import { useWorkspaceRepos } from "../useWorkspaceRepos"; export type OpenReviewDiff = ( path: string, @@ -15,8 +16,9 @@ export function useReviewCommentNavigation( workspaceId: string, onSelectDiffFile?: OpenReviewDiff, ) { + const { repoArg } = useWorkspaceRepos(workspaceId); const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( - { workspaceId }, + { workspaceId, ...repoArg }, { staleTime: Number.POSITIVE_INFINITY }, ); return onSelectDiffFile diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts index cbf779c25b7..19a65e7cce2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts @@ -4,6 +4,7 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useMemo } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { DiffRef } from "../useChangeset/types"; +import { useWorkspaceRepos } from "../useWorkspaceRepos"; export function useSidebarDiffRef(workspaceId: string): DiffRef { const collections = useCollections(); @@ -17,8 +18,9 @@ export function useSidebarDiffRef(workspaceId: string): DiffRef { const sidebarState = rows[0]?.sidebarState; const filter = sidebarState?.changesFilter ?? { kind: "all" }; + const { repoArg } = useWorkspaceRepos(workspaceId); const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( - { workspaceId }, + { workspaceId, ...repoArg }, { staleTime: Number.POSITIVE_INFINITY }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/index.ts new file mode 100644 index 00000000000..a48f89aa8c4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/index.ts @@ -0,0 +1,6 @@ +export { resolveSelectedRepo } from "./resolveSelectedRepo"; +export { + type UseWorkspaceReposResult, + useWorkspaceRepos, + type WorkspaceRepo, +} from "./useWorkspaceRepos"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/resolveSelectedRepo.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/resolveSelectedRepo.test.ts new file mode 100644 index 00000000000..eac80423e86 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/resolveSelectedRepo.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { resolveSelectedRepo } from "./resolveSelectedRepo"; + +const repos = [ + { folder: "api", path: "/w/api" }, + { folder: "web", path: "/w/web" }, +]; + +describe("resolveSelectedRepo", () => { + test("defaults to the primary when nothing is stored", () => { + expect(resolveSelectedRepo(repos, undefined)?.folder).toBe("api"); + }); + + test("returns the stored folder when it still exists", () => { + expect(resolveSelectedRepo(repos, "web")?.path).toBe("/w/web"); + }); + + test("falls back to the primary when the stored folder is gone", () => { + expect(resolveSelectedRepo(repos, "removed")?.folder).toBe("api"); + }); + + test("returns undefined when the workspace has no checkouts", () => { + expect(resolveSelectedRepo([], "web")).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/resolveSelectedRepo.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/resolveSelectedRepo.ts new file mode 100644 index 00000000000..ed59181e99e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/resolveSelectedRepo.ts @@ -0,0 +1,6 @@ +export function resolveSelectedRepo( + repos: readonly Repo[], + storedFolder: string | undefined, +): Repo | undefined { + return repos.find((repo) => repo.folder === storedFolder) ?? repos[0]; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/useWorkspaceRepos.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/useWorkspaceRepos.test.tsx new file mode 100644 index 00000000000..0659bbf9303 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/useWorkspaceRepos.test.tsx @@ -0,0 +1,124 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +interface FakeRepo { + folder: string; + path: string; + projectId: string; + repository: string | null; + branch: string; + base: string | null; +} + +const PRIMARY: FakeRepo = { + folder: "api", + path: "/w/api", + projectId: "p1", + repository: "acme/api", + branch: "feature", + base: "main", +}; +const SECONDARY: FakeRepo = { + folder: "web", + path: "/w/web", + projectId: "p2", + repository: "acme/web", + branch: "feature", + base: "main", +}; + +let workspaceData: { worktreePath: string; repos: FakeRepo[] } = { + worktreePath: "/w/api", + repos: [PRIMARY], +}; + +mock.module("@superset/workspace-client", () => ({ + workspaceTrpc: { + workspace: { + get: { useQuery: () => ({ data: workspaceData, isLoading: false }) }, + }, + }, +})); + +const { act, cleanup, renderHook } = await import("@testing-library/react"); +const { useWorkspaceRepos } = await import("./useWorkspaceRepos"); +const { useSelectedRepoStore } = await import("../../state/selectedRepoStore"); + +beforeEach(() => { + useSelectedRepoStore.setState({ folders: {} }); + workspaceData = { worktreePath: "/w/api", repos: [PRIMARY] }; +}); +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +describe("useWorkspaceRepos", () => { + test("a single-repo workspace keeps the plain worktree and an empty repo arg", () => { + const { result } = renderHook(() => useWorkspaceRepos("ws-1")); + + expect(result.current.hasMultipleRepos).toBe(false); + expect(result.current.rootPath).toBe("/w/api"); + expect(result.current.repoArg).toEqual({}); + }); + + test("switching folders re-roots the tree and retargets git reads", () => { + workspaceData = { + worktreePath: "/w/api", + repos: [PRIMARY, SECONDARY], + }; + const { result } = renderHook(() => useWorkspaceRepos("ws-1")); + + expect(result.current.rootPath).toBe("/w/api"); + expect(result.current.repoArg).toEqual({ repo: "api" }); + + act(() => { + result.current.selectFolder("web"); + }); + + expect(result.current.rootPath).toBe("/w/web"); + expect(result.current.repoArg).toEqual({ repo: "web" }); + }); + + test("a stored folder that no longer exists falls back to the primary", () => { + useSelectedRepoStore.setState({ folders: { "ws-1": "removed" } }); + workspaceData = { + worktreePath: "/w/api", + repos: [PRIMARY, SECONDARY], + }; + const { result } = renderHook(() => useWorkspaceRepos("ws-1")); + + expect(result.current.selected?.folder).toBe("api"); + expect(result.current.rootPath).toBe("/w/api"); + expect(result.current.repoArg).toEqual({ repo: "api" }); + }); + + test("selections are scoped to their own workspace", () => { + workspaceData = { + worktreePath: "/w/api", + repos: [PRIMARY, SECONDARY], + }; + const first = renderHook(() => useWorkspaceRepos("ws-1")); + act(() => { + first.result.current.selectFolder("web"); + }); + const second = renderHook(() => useWorkspaceRepos("ws-2")); + + expect(first.result.current.selected?.folder).toBe("web"); + expect(second.result.current.selected?.folder).toBe("api"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/useWorkspaceRepos.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/useWorkspaceRepos.ts new file mode 100644 index 00000000000..1eb5f949dd9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos/useWorkspaceRepos.ts @@ -0,0 +1,75 @@ +import type { AppRouter } from "@superset/host-service"; +import { workspaceTrpc } from "@superset/workspace-client"; +import type { inferRouterOutputs } from "@trpc/server"; +import { useCallback, useMemo } from "react"; +import { useSelectedRepoStore } from "../../state/selectedRepoStore"; +import { resolveSelectedRepo } from "./resolveSelectedRepo"; + +export type WorkspaceRepo = + inferRouterOutputs["workspace"]["get"]["repos"][number]; + +const PRIMARY_REPO_ARG: { repo?: string } = {}; + +const NO_REPOS: WorkspaceRepo[] = []; + +const WORKSPACE_REPOS_STALE_TIME_MS = 30_000; + +export interface UseWorkspaceReposResult { + repos: WorkspaceRepo[]; + selected: WorkspaceRepo | undefined; + hasMultipleRepos: boolean; + /** Absolute path of the selected checkout, not the multi-repo container. */ + rootPath: string; + repoArg: { repo?: string }; + selectFolder: (folder: string) => void; + isLoading: boolean; +} + +/** + * The workspace's checkouts plus which one the UI is scoped to. Shares + * `workspace.get` with the route, so the dozen call sites cost one request. + */ +export function useWorkspaceRepos( + workspaceId: string, +): UseWorkspaceReposResult { + const workspaceQuery = workspaceTrpc.workspace.get.useQuery( + { id: workspaceId }, + { staleTime: WORKSPACE_REPOS_STALE_TIME_MS, enabled: Boolean(workspaceId) }, + ); + const storedFolder = useSelectedRepoStore( + (state) => state.folders[workspaceId], + ); + const selectFolderInStore = useSelectedRepoStore( + (state) => state.selectFolder, + ); + + const repos = workspaceQuery.data?.repos ?? NO_REPOS; + const selected = resolveSelectedRepo(repos, storedFolder); + const hasMultipleRepos = repos.length > 1; + + const selectFolder = useCallback( + (folder: string) => selectFolderInStore(workspaceId, folder), + [selectFolderInStore, workspaceId], + ); + + // Stable per folder so the query inputs and callbacks that spread it + // don't change identity on every render. + const selectedFolder = hasMultipleRepos ? selected?.folder : undefined; + const repoArg = useMemo( + () => (selectedFolder ? { repo: selectedFolder } : PRIMARY_REPO_ARG), + [selectedFolder], + ); + + return { + repos, + selected, + hasMultipleRepos, + rootPath: + hasMultipleRepos && selected + ? selected.path + : (workspaceQuery.data?.worktreePath ?? ""), + repoArg, + selectFolder, + isLoading: workspaceQuery.isLoading, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/WorkspaceGitStatusProvider/WorkspaceGitStatusProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/WorkspaceGitStatusProvider/WorkspaceGitStatusProvider.tsx index 8f2b12cc9e0..b09ae10a67c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/WorkspaceGitStatusProvider/WorkspaceGitStatusProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/providers/WorkspaceGitStatusProvider/WorkspaceGitStatusProvider.tsx @@ -1,5 +1,6 @@ import { createContext, useContext } from "react"; import { useGitStatus } from "renderer/hooks/host-service/useGitStatus"; +import { useWorkspaceRepos } from "../../hooks/useWorkspaceRepos"; type WorkspaceGitStatus = ReturnType; @@ -21,7 +22,8 @@ export function WorkspaceGitStatusProvider({ children, workspaceId, }: WorkspaceGitStatusProviderProps) { - const gitStatus = useGitStatus(workspaceId, true); + const { repoArg } = useWorkspaceRepos(workspaceId); + const gitStatus = useGitStatus(workspaceId, true, repoArg); return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/selectedRepoStore/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/selectedRepoStore/index.ts new file mode 100644 index 00000000000..2fb2572e635 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/selectedRepoStore/index.ts @@ -0,0 +1 @@ +export { useSelectedRepoStore } from "./selectedRepoStore"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/selectedRepoStore/selectedRepoStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/selectedRepoStore/selectedRepoStore.ts new file mode 100644 index 00000000000..fcf93a672bd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/selectedRepoStore/selectedRepoStore.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +interface SelectedRepoStore { + /** Selected checkout folder by workspace id. */ + folders: Record; + selectFolder: (workspaceId: string, folder: string) => void; +} + +/** + * Deliberately not persisted: workspace-keyed with unbounded cardinality, + * which `apps/desktop/AGENTS.md` bars from localStorage, and there is no + * host-side UI-state store to hold it instead (as `rowlessSidebarTabStore`). + */ +export const useSelectedRepoStore = create()((set) => ({ + folders: {}, + selectFolder: (workspaceId, folder) => + set((state) => ({ folders: { ...state.folders, [workspaceId]: folder } })), +})); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.test.tsx new file mode 100644 index 00000000000..5bd8c2bda0c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.test.tsx @@ -0,0 +1,110 @@ +import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom over the preloaded plain-object document. Process-wide, so this +// unregisters in afterAll to leave the other renderer suites their document. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const { act, cleanup, fireEvent, render, within } = await import( + "@testing-library/react" +); +const { MultiRepoProjectForm } = await import( + "./components/MultiRepoProjectForm" +); +const { appendFolder, removeFolder } = await import( + "./MultiRepoProjectModal.utils" +); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +const FOLDERS = appendFolder(appendFolder([], "/repos/api"), "/repos/web"); + +async function renderForm( + overrides: Partial[0]> = {}, +) { + const onRemoveFolder = mock((_path: string) => {}); + let view!: ReturnType; + await act(async () => { + view = render( + {}} + onAddFolder={() => {}} + onRemoveFolder={onRemoveFolder} + onCancel={() => {}} + onCreate={() => {}} + {...overrides} + />, + ); + }); + return { ui: within(view.baseElement as HTMLElement), onRemoveFolder }; +} + +function createButton(ui: Awaited>["ui"]) { + return ui.getByText("Create project").closest("button"); +} + +describe("the Create project button", () => { + test("is disabled until a folder is picked", async () => { + const empty = await renderForm({ folders: [] }); + expect(createButton(empty.ui)?.disabled).toBe(true); + + cleanup(); + + const picked = await renderForm(); + expect(createButton(picked.ui)?.disabled).toBe(false); + }); +}); + +describe("the Primary badge", () => { + test("sits on the first folder and nowhere else", async () => { + const { ui } = await renderForm(); + + expect(ui.getAllByTestId("selected-folder-primary-badge")).toHaveLength(1); + const badged = ui + .getByTestId("selected-folder-primary-badge") + .closest("[data-testid=selected-folder-row]"); + expect(badged?.getAttribute("data-folder")).toBe("api"); + }); +}); + +describe("removing a folder before creating", () => { + test("asks the parent to drop that path and re-badges the new first row", async () => { + const { ui, onRemoveFolder } = await renderForm(); + + await act(async () => { + fireEvent.click(ui.getByLabelText("Remove api")); + }); + expect(onRemoveFolder).toHaveBeenCalledWith("/repos/api"); + + cleanup(); + + const remaining = removeFolder(FOLDERS, "/repos/api"); + const after = await renderForm({ folders: remaining }); + const badged = after.ui + .getByTestId("selected-folder-primary-badge") + .closest("[data-testid=selected-folder-row]"); + expect(badged?.getAttribute("data-folder")).toBe("web"); + }); + + test("is refused for folders the host already accepted", async () => { + const { ui } = await renderForm({ committedPaths: ["/repos/api"] }); + + expect(ui.getByLabelText("Remove api").hasAttribute("disabled")).toBe(true); + expect(ui.getByLabelText("Remove web").hasAttribute("disabled")).toBe( + false, + ); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.tsx new file mode 100644 index 00000000000..9b70a38b076 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.tsx @@ -0,0 +1,180 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { errorMessage } from "@superset/i18n/errors"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { toast } from "@superset/ui/sonner"; +import { useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { showHostServiceUnavailableToast } from "renderer/lib/host-service-unavailable"; +import { useFinalizeProjectSetup } from "renderer/react-query/projects"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { MultiRepoProjectForm } from "./components/MultiRepoProjectForm"; +import { + appendFolder, + removeFolder, + type SelectedFolder, +} from "./MultiRepoProjectModal.utils"; + +interface MultiRepoProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: (result: { projectId: string }) => void; +} + +export function MultiRepoProjectModal({ + open, + onOpenChange, + onSuccess, +}: MultiRepoProjectModalProps) { + const { t } = useLingui(); + const hostService = useLocalHostService(); + const finalizeSetup = useFinalizeProjectSetup(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + + const [name, setName] = useState(""); + const [folders, setFolders] = useState([]); + const [working, setWorking] = useState(false); + const [failure, setFailure] = useState(null); + const [projectId, setProjectId] = useState(null); + const [committedPaths, setCommittedPaths] = useState([]); + + const reset = () => { + setName(""); + setFolders([]); + setWorking(false); + setFailure(null); + setProjectId(null); + setCommittedPaths([]); + }; + + const handleOpenChange = (next: boolean) => { + if (!next && working) return; + if (!next) reset(); + onOpenChange(next); + }; + + const handleAddFolder = async () => { + try { + const picked = await selectDirectory.mutateAsync({ + title: t({ message: "Select a source folder" }), + }); + if (picked.canceled || !picked.path) return; + const path = picked.path; + if (folders.some((folder) => folder.path === path)) { + toast.error(t({ message: "That folder is already in the list" })); + return; + } + setFolders(appendFolder(folders, path)); + } catch (error) { + toast.error(errorMessage(error)); + } + }; + + const createProject = async () => { + const primary = folders[0]; + if (!primary) return; + + setWorking(true); + setFailure(null); + try { + const hostUrl = await hostService.waitForHostReady(); + if (!hostUrl) { + showHostServiceUnavailableToast(hostService, { + action: "createProject", + }); + return; + } + const client = getHostServiceClientByUrl(hostUrl); + + const attached = new Set(committedPaths); + let createdProjectId = projectId; + if (!createdProjectId) { + try { + const result = await client.project.create.mutate({ + name: name.trim() || primary.name, + mode: { kind: "importLocal", repoPath: primary.path }, + }); + finalizeSetup(hostUrl, result); + createdProjectId = result.projectId; + setProjectId(createdProjectId); + } catch (error) { + setFailure( + t({ + message: `Could not create the project from "${primary.name}": ${errorMessage(error)}`, + }), + ); + return; + } + attached.add(primary.path); + setCommittedPaths([...attached]); + } + + for (const folder of folders.slice(1)) { + if (attached.has(folder.path)) continue; + try { + await client.project.folders.add.mutate({ + projectId: createdProjectId, + repoPath: folder.path, + folder: folder.name, + }); + } catch (error) { + setCommittedPaths([...attached]); + setFailure( + t({ + message: `The project was created, but "${folder.name}" could not be added: ${errorMessage(error)}. The folders added before it were kept — remove it or fix it and create again.`, + }), + ); + return; + } + attached.add(folder.path); + } + + setCommittedPaths([...attached]); + onSuccess({ projectId: createdProjectId }); + reset(); + onOpenChange(false); + } catch (error) { + setFailure(errorMessage(error)); + } finally { + setWorking(false); + } + }; + + return ( + + + + + Create project + + + + Group several repositories into one project. Every workspace + checks out all of them. + + + + + void handleAddFolder()} + onRemoveFolder={(path) => setFolders(removeFolder(folders, path))} + onCancel={() => handleOpenChange(false)} + onCreate={() => void createProject()} + /> + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.utils.ts new file mode 100644 index 00000000000..5611c93c7c6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.utils.ts @@ -0,0 +1,41 @@ +export interface SelectedFolder { + path: string; + name: string; +} + +const MAX_FOLDER_NAME_LENGTH = 64; + +/** + * Mirrors `sanitizeFolderName` on the host, so the row shows the name the + * server will store rather than one that changes on create. + */ +export function folderNameForPath(path: string): string { + const base = path.split(/[\\/]/).filter(Boolean).at(-1) ?? ""; + const name = base + .replace(/[^A-Za-z0-9._-]/g, "-") + .replace(/^[^A-Za-z0-9]+/, "") + .slice(0, MAX_FOLDER_NAME_LENGTH); + return name || "repo"; +} + +export function appendFolder( + folders: SelectedFolder[], + path: string, +): SelectedFolder[] { + const taken = new Set(folders.map((folder) => folder.name)); + const base = folderNameForPath(path); + let name = base; + let suffix = 2; + while (taken.has(name)) { + name = `${base}-${suffix}`; + suffix += 1; + } + return [...folders, { path, name }]; +} + +export function removeFolder( + folders: SelectedFolder[], + path: string, +): SelectedFolder[] { + return folders.filter((folder) => folder.path !== path); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/MultiRepoProjectForm.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/MultiRepoProjectForm.tsx new file mode 100644 index 00000000000..0e9aea9cbb3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/MultiRepoProjectForm.tsx @@ -0,0 +1,132 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Button } from "@superset/ui/button"; +import { DialogFooter } from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { LuLoaderCircle, LuPlus } from "react-icons/lu"; +import type { SelectedFolder } from "../../MultiRepoProjectModal.utils"; +import { SelectedFolderRow } from "./components/SelectedFolderRow"; + +interface MultiRepoProjectFormProps { + name: string; + folders: SelectedFolder[]; + /** Already attached to the project on the host, so no longer removable. */ + committedPaths: string[]; + failure: string | null; + isWorking: boolean; + isPicking: boolean; + onNameChange: (name: string) => void; + onAddFolder: () => void; + onRemoveFolder: (path: string) => void; + onCancel: () => void; + onCreate: () => void; +} + +export function MultiRepoProjectForm({ + name, + folders, + committedPaths, + failure, + isWorking, + isPicking, + onNameChange, + onAddFolder, + onRemoveFolder, + onCancel, + onCreate, +}: MultiRepoProjectFormProps) { + const { t } = useLingui(); + const primaryFolder = folders[0]; + + return ( + <> +
+
+ + onNameChange(event.target.value)} + placeholder={primaryFolder?.name ?? t({ message: "my-project" })} + disabled={isWorking} + autoFocus + /> +
+ +
+ +
+ {folders.map((folder) => ( + onRemoveFolder(folder.path)} + /> + ))} + {folders.length === 0 && ( +

+ + Add the repositories this project works across. The first one + is the primary. + +

+ )} +
+ +
+
+
+ + {failure && ( +

+ {failure} +

+ )} +
+ + + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/components/SelectedFolderRow/SelectedFolderRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/components/SelectedFolderRow/SelectedFolderRow.tsx new file mode 100644 index 00000000000..2b274defcea --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/components/SelectedFolderRow/SelectedFolderRow.tsx @@ -0,0 +1,63 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { LuFolder, LuX } from "react-icons/lu"; +import type { SelectedFolder } from "../../../../MultiRepoProjectModal.utils"; + +interface SelectedFolderRowProps { + folder: SelectedFolder; + isPrimary: boolean; + canRemove: boolean; + onRemove: () => void; +} + +export function SelectedFolderRow({ + folder, + isPrimary, + canRemove, + onRemove, +}: SelectedFolderRowProps) { + const { t } = useLingui(); + + return ( +
+ +
+
+ {folder.name} + {isPrimary && ( + + + Primary + + + )} +
+

+ {folder.path} +

+
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/components/SelectedFolderRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/components/SelectedFolderRow/index.ts new file mode 100644 index 00000000000..8837db4d8ed --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/components/SelectedFolderRow/index.ts @@ -0,0 +1 @@ +export { SelectedFolderRow } from "./SelectedFolderRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/index.ts new file mode 100644 index 00000000000..4638a8a51eb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/components/MultiRepoProjectForm/index.ts @@ -0,0 +1 @@ +export { MultiRepoProjectForm } from "./MultiRepoProjectForm"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/index.ts new file mode 100644 index 00000000000..942e06cff04 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/index.ts @@ -0,0 +1 @@ +export { MultiRepoProjectModal } from "./MultiRepoProjectModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index dd23de5c18b..0e4a47df35c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -1,7 +1,9 @@ import { Trans, useLingui } from "@lingui/react/macro"; +import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Label } from "@superset/ui/label"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; +import { useFeatureFlagEnabled } from "posthog-js/react"; import { useEffect, useMemo, useRef } from "react"; import { PROJECT_ICON_NONE, @@ -26,6 +28,7 @@ import { NameSection } from "./components/NameSection"; import { NamingInstructionsSection } from "./components/NamingInstructionsSection"; import { ProjectLocationSection } from "./components/ProjectLocationSection"; import { RepositorySection } from "./components/RepositorySection"; +import { SourceFoldersSection } from "./components/SourceFoldersSection"; import { SparseCheckoutSection } from "./components/SparseCheckoutSection"; import { V2ScriptsEditor } from "./components/V2ScriptsEditor"; import { WorktreeLocationSection } from "./components/WorktreeLocationSection"; @@ -44,6 +47,12 @@ export function V2ProjectSettings({ }: V2ProjectSettingsProps) { const navigate = useNavigate(); const { t } = useLingui(); + // TEMPORARY local-dev override — revert before committing. + // PostHog runs with a disabled key locally, so flags never resolve and every + // gated feature is invisible in dev. + const isMultiRepoEnabled = + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? + import.meta.env.DEV; const { machineId } = useLocalHostService(); const { currentDeviceName, localHostId, otherHosts } = useWorkspaceHostOptions(); @@ -237,6 +246,16 @@ export function V2ProjectSettings({ + {isMultiRepoEnabled && ( + + )} + { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +function folder( + id: string, + name: string, + position: number, +): ProjectFolderShape { + return { + id, + projectId: "project-1", + position, + folder: name, + repoPath: `/repos/${name}`, + repoUrl: null, + baseBranch: null, + }; +} + +const FOLDERS: ProjectFolderShape[] = [ + folder("f-api", "api", 0), + folder("f-web", "web", 1), +]; + +async function renderList(folders: ProjectFolderShape[]) { + let view!: ReturnType; + await act(async () => { + view = render( + folders.map((entry) => ( + {}} + onRename={() => {}} + onRemove={() => {}} + /> + )), + ); + }); + return within(view.baseElement as HTMLElement); +} + +async function renderMenu(isPrimary: boolean) { + const onRemove = mock(() => {}); + let view!: ReturnType; + await act(async () => { + view = render( + + + {}} + onRename={() => {}} + onRemove={onRemove} + /> + + , + ); + }); + return { ui: within(view.baseElement as HTMLElement), onRemove }; +} + +function primaryRowName(ui: Awaited>): string { + const badge = ui.getByTestId("source-folder-primary-badge"); + return ( + badge + .closest("[data-testid=source-folder-row]") + ?.getAttribute("data-folder") ?? "" + ); +} + +describe("the Primary badge", () => { + test("sits on the folder at position 0 and nowhere else", async () => { + const ui = await renderList(FOLDERS); + + expect(ui.getAllByTestId("source-folder-primary-badge")).toHaveLength(1); + expect(primaryRowName(ui)).toBe("api"); + }); +}); + +describe("the primary folder's actions", () => { + test("offer Remove as a disabled item with a reason", async () => { + const { ui, onRemove } = await renderMenu(true); + const item = ui.getByText("Remove").closest("[role=menuitem]"); + + expect(item?.getAttribute("data-disabled")).not.toBeNull(); + expect(onRemove).not.toHaveBeenCalled(); + + const trigger = ui.getByTestId("source-folder-remove-reason"); + await act(async () => { + fireEvent.focus(trigger); + }); + expect( + ui.getAllByText( + "The primary folder can't be removed. Make another folder primary first.", + ).length, + ).toBeGreaterThan(0); + }); + + test("omit Make primary, which it already is", async () => { + const { ui } = await renderMenu(true); + + expect(ui.queryByText("Make primary")).toBeNull(); + }); +}); + +describe("making another folder primary", () => { + test("moves it to the front and hands it the badge", async () => { + const reordered = withPrimaryFolder(FOLDERS, "f-web"); + + expect(reordered.map((entry) => entry.folder)).toEqual(["web", "api"]); + expect(reordered.map((entry) => entry.position)).toEqual([0, 1]); + + const ui = await renderList(reordered); + expect(ui.getAllByTestId("source-folder-primary-badge")).toHaveLength(1); + expect(primaryRowName(ui)).toBe("web"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx new file mode 100644 index 00000000000..3f47bf720f6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx @@ -0,0 +1,233 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { errorMessage } from "@superset/i18n/errors"; +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { LuPlus } from "react-icons/lu"; +import { RemotePathPicker } from "renderer/components/RemotePathPicker"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { SettingsSection } from "../../../../../../components/SettingsSection"; +import { RemoveFolderDialog } from "./components/RemoveFolderDialog"; +import { RenameFolderDialog } from "./components/RenameFolderDialog"; +import { SourceFolderRow } from "./components/SourceFolderRow"; +import { withoutFolder, withPrimaryFolder } from "./SourceFoldersSection.utils"; +import type { ProjectFolder } from "./types"; + +interface SourceFoldersSectionProps { + projectId: string; + hostUrl: string | null; + hostName: string; + isRemoteTarget: boolean; + /** The host has a row for this project; `project.folders.*` 404s otherwise. */ + isProjectSetup: boolean; +} + +export function SourceFoldersSection({ + projectId, + hostUrl, + hostName, + isRemoteTarget, + isProjectSetup, +}: SourceFoldersSectionProps) { + const { t } = useLingui(); + const queryClient = useQueryClient(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + const [renameTarget, setRenameTarget] = useState(null); + const [removeTarget, setRemoveTarget] = useState(null); + const [browseOpen, setBrowseOpen] = useState(false); + + const queryKey = ["project-folders", "list", hostUrl, projectId]; + const foldersQuery = useQuery({ + queryKey, + enabled: Boolean(hostUrl) && isProjectSetup, + queryFn: async () => { + if (!hostUrl) return { folders: [] as ProjectFolder[] }; + return getHostServiceClientByUrl(hostUrl).project.folders.list.query({ + projectId, + }); + }, + }); + const folders = foldersQuery.data?.folders ?? []; + + const runOptimistically = async ( + next: ProjectFolder[], + request: () => Promise, + ) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, { folders: next }); + try { + await request(); + } catch (error) { + queryClient.setQueryData(queryKey, previous); + throw error; + } finally { + void queryClient.invalidateQueries({ queryKey }); + } + }; + + const client = () => { + if (!hostUrl) throw new Error("Host unavailable"); + return getHostServiceClientByUrl(hostUrl); + }; + + const setPrimary = useMutation({ + mutationFn: (folder: ProjectFolder) => + runOptimistically(withPrimaryFolder(folders, folder.id), () => + client().project.folders.setPrimary.mutate({ + projectId, + folderId: folder.id, + }), + ), + onError: (error) => toast.error(errorMessage(error)), + }); + + const remove = useMutation({ + mutationFn: (folder: ProjectFolder) => + runOptimistically(withoutFolder(folders, folder.id), () => + client().project.folders.remove.mutate({ + projectId, + folderId: folder.id, + }), + ), + onSuccess: () => setRemoveTarget(null), + onError: (error) => toast.error(errorMessage(error)), + }); + + const rename = useMutation({ + mutationFn: ({ folder, name }: { folder: ProjectFolder; name: string }) => + runOptimistically( + folders.map((candidate) => + candidate.id === folder.id + ? { ...candidate, folder: name } + : candidate, + ), + () => + client().project.folders.rename.mutate({ + projectId, + folderId: folder.id, + folder: name, + }), + ), + onSuccess: () => setRenameTarget(null), + onError: (error) => toast.error(errorMessage(error)), + }); + + const add = useMutation({ + mutationFn: (repoPath: string) => + client().project.folders.add.mutate({ projectId, repoPath }), + onSuccess: (result) => { + toast.success( + t({ message: `Added ${result.folder.folder} to this project` }), + ); + void queryClient.invalidateQueries({ queryKey }); + }, + onError: (error) => toast.error(errorMessage(error)), + }); + + const isBusy = + setPrimary.isPending || + remove.isPending || + rename.isPending || + add.isPending; + + const handleAdd = async () => { + if (isRemoteTarget) { + setBrowseOpen(true); + return; + } + try { + const picked = await selectDirectory.mutateAsync({ + title: t({ message: "Select a repository to add" }), + }); + if (picked.canceled || !picked.path) return; + add.mutate(picked.path); + } catch (error) { + toast.error(errorMessage(error)); + } + }; + + return ( + +
+ {folders.map((folder) => ( + setPrimary.mutate(folder)} + onRename={() => setRenameTarget(folder)} + onRemove={() => setRemoveTarget(folder)} + /> + ))} + {folders.length === 0 && ( +

+ {foldersQuery.isLoading ? ( + Loading folders… + ) : ( + This project has no folders on {hostName} yet. + )} +

+ )} +
+ +
+
+ + folder.folder)} + isSubmitting={rename.isPending} + onOpenChange={(open) => { + if (!open) setRenameTarget(null); + }} + onSubmit={(name) => { + if (renameTarget) rename.mutate({ folder: renameTarget, name }); + }} + /> + + { + if (!open) setRemoveTarget(null); + }} + onConfirm={() => { + if (removeTarget) remove.mutate(removeTarget); + }} + /> + + add.mutate(path)} + /> +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.utils.ts new file mode 100644 index 00000000000..9a90687f977 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.utils.ts @@ -0,0 +1,23 @@ +import type { ProjectFolder } from "./types"; + +/** Optimistic mirror of `project.folders.setPrimary`. */ +export function withPrimaryFolder( + folders: ProjectFolder[], + folderId: string, +): ProjectFolder[] { + const target = folders.find((folder) => folder.id === folderId); + if (!target) return folders; + return [target, ...folders.filter((folder) => folder.id !== folderId)].map( + (folder, position) => ({ ...folder, position }), + ); +} + +/** Optimistic mirror of `project.folders.remove`, which closes the gap. */ +export function withoutFolder( + folders: ProjectFolder[], + folderId: string, +): ProjectFolder[] { + return folders + .filter((folder) => folder.id !== folderId) + .map((folder, position) => ({ ...folder, position })); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RemoveFolderDialog/RemoveFolderDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RemoveFolderDialog/RemoveFolderDialog.tsx new file mode 100644 index 00000000000..8cb88c62ace --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RemoveFolderDialog/RemoveFolderDialog.tsx @@ -0,0 +1,58 @@ +import { Trans } from "@lingui/react/macro"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import type { ProjectFolder } from "../../types"; + +interface RemoveFolderDialogProps { + folder: ProjectFolder | null; + isSubmitting: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +} + +export function RemoveFolderDialog({ + folder, + isSubmitting, + onOpenChange, + onConfirm, +}: RemoveFolderDialogProps) { + return ( + + + + + Remove {folder?.folder} from this project? + + + + New workspaces will stop checking this repository out. Existing + workspaces keep it, and nothing is deleted from disk. + + + + + + Cancel + + { + event.preventDefault(); + onConfirm(); + }} + disabled={isSubmitting} + > + Remove folder + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RemoveFolderDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RemoveFolderDialog/index.ts new file mode 100644 index 00000000000..7152e351dab --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RemoveFolderDialog/index.ts @@ -0,0 +1 @@ +export { RemoveFolderDialog } from "./RemoveFolderDialog"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RenameFolderDialog/RenameFolderDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RenameFolderDialog/RenameFolderDialog.tsx new file mode 100644 index 00000000000..2ac77fa459d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RenameFolderDialog/RenameFolderDialog.tsx @@ -0,0 +1,113 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { useEffect, useState } from "react"; +import type { ProjectFolder } from "../../types"; + +/** Mirrors `sanitizeFolderName` on the host; keep the two in step. */ +const FOLDER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const FOLDER_NAME_MAX_LENGTH = 64; + +interface RenameFolderDialogProps { + folder: ProjectFolder | null; + takenNames: string[]; + isSubmitting: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (name: string) => void; +} + +export function RenameFolderDialog({ + folder, + takenNames, + isSubmitting, + onOpenChange, + onSubmit, +}: RenameFolderDialogProps) { + const { t } = useLingui(); + const [name, setName] = useState(""); + + useEffect(() => { + if (folder) setName(folder.folder); + }, [folder]); + + const trimmed = name.trim(); + const isTaken = takenNames.some( + (taken) => + taken.toLowerCase() !== folder?.folder.toLowerCase() && + taken.toLowerCase() === trimmed.toLowerCase(), + ); + const error = !trimmed + ? null + : trimmed.length > FOLDER_NAME_MAX_LENGTH || + !FOLDER_NAME_PATTERN.test(trimmed) + ? t({ + message: + "Use letters, digits, dots, dashes or underscores, starting with a letter or digit.", + }) + : isTaken + ? t({ + message: "Another folder in this project already has that name.", + }) + : null; + const canSubmit = + Boolean(trimmed) && !error && !isSubmitting && trimmed !== folder?.folder; + + return ( + + + + + Rename folder + + + + This is the directory the repository is checked out into inside + each workspace. + + + +
{ + event.preventDefault(); + if (canSubmit) onSubmit(trimmed); + }} + > + + setName(event.target.value)} + /> + {error &&

{error}

} + + + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RenameFolderDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RenameFolderDialog/index.ts new file mode 100644 index 00000000000..d4a83fb810b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/RenameFolderDialog/index.ts @@ -0,0 +1 @@ +export { RenameFolderDialog } from "./RenameFolderDialog"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/SourceFolderRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/SourceFolderRow.tsx new file mode 100644 index 00000000000..52ef46be19c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/SourceFolderRow.tsx @@ -0,0 +1,89 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { HiEllipsisVertical } from "react-icons/hi2"; +import { LuFolder } from "react-icons/lu"; +import type { ProjectFolder } from "../../types"; +import { SourceFolderMenuItems } from "./components/SourceFolderMenuItems"; + +interface SourceFolderRowProps { + folder: ProjectFolder; + isPrimary: boolean; + disabled?: boolean; + onMakePrimary: () => void; + onRename: () => void; + onRemove: () => void; +} + +export function SourceFolderRow({ + folder, + isPrimary, + disabled, + onMakePrimary, + onRename, + onRemove, +}: SourceFolderRowProps) { + const { t } = useLingui(); + const source = + folder.repoPath ?? + folder.repoUrl ?? + t({ message: "Cloned into each new workspace" }); + + return ( +
+ +
+
+ {folder.folder} + {isPrimary && ( + + + Primary + + + )} +
+

+ {source} +

+
+ + + + + + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/SourceFolderMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/SourceFolderMenuItems.tsx new file mode 100644 index 00000000000..b7ab0a0b2df --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/SourceFolderMenuItems.tsx @@ -0,0 +1,79 @@ +import { Trans } from "@lingui/react/macro"; +import { DropdownMenuItem } from "@superset/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { HiOutlineTrash } from "react-icons/hi2"; +import { LuPencil, LuStar } from "react-icons/lu"; + +interface SourceFolderMenuItemsProps { + isPrimary: boolean; + disabled?: boolean; + onMakePrimary: () => void; + onRename: () => void; + onRemove: () => void; +} + +export function SourceFolderMenuItems({ + isPrimary, + disabled, + onMakePrimary, + onRename, + onRemove, +}: SourceFolderMenuItemsProps) { + return ( + <> + {!isPrimary && ( + + + + Make primary + + + )} + + + + Rename folder + + + {isPrimary ? ( + + + + + + + Remove + + + + + + + The primary folder can't be removed. Make another folder primary + first. + + + + ) : ( + + + + Remove + + + )} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/index.ts new file mode 100644 index 00000000000..bb9391aae25 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/index.ts @@ -0,0 +1 @@ +export { SourceFolderMenuItems } from "./SourceFolderMenuItems"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/index.ts new file mode 100644 index 00000000000..f01302b448f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/index.ts @@ -0,0 +1 @@ +export { SourceFolderRow } from "./SourceFolderRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/index.ts new file mode 100644 index 00000000000..f01423d277d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/index.ts @@ -0,0 +1 @@ +export { SourceFoldersSection } from "./SourceFoldersSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/types.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/types.ts new file mode 100644 index 00000000000..cde744c4cea --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/types.ts @@ -0,0 +1,10 @@ +/** A row of `project.folders.list`. Position 0 is the project's primary. */ +export interface ProjectFolder { + id: string; + projectId: string; + position: number; + folder: string; + repoPath: string | null; + repoUrl: string | null; + baseBranch: string | null; +} diff --git a/apps/desktop/src/renderer/stores/add-repository-modal.ts b/apps/desktop/src/renderer/stores/add-repository-modal.ts index 6e913e6b4f8..8c9c9636b2d 100644 --- a/apps/desktop/src/renderer/stores/add-repository-modal.ts +++ b/apps/desktop/src/renderer/stores/add-repository-modal.ts @@ -9,6 +9,7 @@ type ActiveModal = | { kind: "none" } | { kind: "new-project" } | { kind: "empty-project" } + | { kind: "multi-repo-project" } | { kind: "template-gallery" }; interface AddRepositoryModalState { @@ -22,6 +23,7 @@ interface AddRepositoryModalState { */ openNewProject: () => Promise; openEmptyProject: () => Promise; + openMultiRepoProject: () => Promise; openTemplateGallery: () => Promise; resolveNewProject: (result: NewProjectResult | null) => void; close: () => void; @@ -50,6 +52,13 @@ export const useAddRepositoryModalStore = create()( set({ active: { kind: "empty-project" } }); }); }, + openMultiRepoProject: () => { + pendingResolve?.(null); + return new Promise((resolve) => { + pendingResolve = resolve; + set({ active: { kind: "multi-repo-project" } }); + }); + }, openTemplateGallery: () => { pendingResolve?.(null); return new Promise((resolve) => { @@ -80,6 +89,8 @@ export const useOpenNewProjectModal = () => useAddRepositoryModalStore((state) => state.openNewProject); export const useOpenEmptyProjectModal = () => useAddRepositoryModalStore((state) => state.openEmptyProject); +export const useOpenMultiRepoProjectModal = () => + useAddRepositoryModalStore((state) => state.openMultiRepoProject); export const useOpenTemplateGalleryModal = () => useAddRepositoryModalStore((state) => state.openTemplateGallery); export const useResolveNewProjectModal = () => diff --git a/apps/mobile/hooks/useCloudWorkspaceItems/useCloudWorkspaceItems.ts b/apps/mobile/hooks/useCloudWorkspaceItems/useCloudWorkspaceItems.ts index 9d05339eadf..2797d87251b 100644 --- a/apps/mobile/hooks/useCloudWorkspaceItems/useCloudWorkspaceItems.ts +++ b/apps/mobile/hooks/useCloudWorkspaceItems/useCloudWorkspaceItems.ts @@ -47,6 +47,8 @@ function itemFromCloudRow(cloud: CloudWorkspaceRow): CloudWorkspaceItem { // Agent activity is stamped by the sandbox host; unknown until opened. lastActivityAt: null, worktreePath: "", + rootPath: null, + repos: [], worktreeExists: true, projectName: null, archivedAt: null, diff --git a/docs/pages/multi-repo-competitive-scan.html b/docs/pages/multi-repo-competitive-scan.html new file mode 100644 index 00000000000..244a767857d --- /dev/null +++ b/docs/pages/multi-repo-competitive-scan.html @@ -0,0 +1,945 @@ + + + + + +Multi-repo workspaces: the competitive scan + + + + +
+
+
Competitive scan · companion to the multi-repo projects PRD
+

Every piece of this exists.
Nobody has assembled it.

+

+ Ten tools were checked for how they handle work that spans several repositories. VS Code has + shown per-repo diffs since 2017. Capy and Factory isolate every task in its own VM. Codex has + a project-level folder list with a primary. DevSwarm has per-repo pull requests. Not one of + them has all four at once — and the tools closest to being an agent workspace are the ones + furthest from the git surfaces. That gap is the opening, and it decides what our v0 is. +

+
+ 17 Sep 2026 · v2, claims verified + Conductor · Cursor · Codex · Kilo · Devin · DevSwarm + PRD: docs/prd/multi-repo-projects.md +
+
+
+ +
+ +
+

The finding

+

Three things the scan settled.

+
+
+ 01 +

The cheap version is now table stakes

+

+ "One agent sees N folders, git stays on one repo" is what Conductor, Cursor and Codex + all ship today. Building it is days of work and buys no differentiation — but not + having it is now a visible gap. +

+
+
+ 02 +

Per-repo diffs aren't novel — the editors have had them for years

+

+ VS Code's Source Control view groups changes by repository in a multi-root workspace, with + independent commits per repo; Cursor and Kilo inherit it. DevSwarm goes further with per-repo + PRs, bought with a submodule bundle. What's missing everywhere is pairing that with a + project-owned repo list and real isolation. +

+
+
+ 03 +

Their isolation is a half-truth

+

+ Codex worktrees cover the primary repo only; secondaries are shared directories that + parallel threads trample. Conductor links live worktrees another agent may be driving. + We already have the machinery to not do this. +

+
+
+
+ +
+

Four things called "multi-repo"

+

Most of the disagreement in this space is a vocabulary problem.

+ +

+ Nine tools now claim multi-repo support and they mean four different things. Sorting them + first makes the rest of the page smaller, and it's why the matrix below only covers tiers 2 + and 3 — comparing a tier-1 tool on "per-repo diff" scores it ○ for a capability it never + set out to have. +

+ +
+
+ 1 +
+

Multi-repo access — one task, one repo

+

+ The agent is entitled to many repositories; any individual task still targets one. + Capy: you pick repos when installing the GitHub App, then each task gets + its own branch capy/<task-slug>-<id> and its own PR. Devin + bulk-indexes repos and groups sessions by target repository. Useful, and not the problem + this PRD is about. +

+
+
+
+ 2 +
+

Multi-repo context — one agent sees N folders, git owns one

+

+ Where almost everyone is: VS Code multi-root (the original), + Cursor (inherits it), Codex (primary folder + secondaries), + Conductor (/add-dir), Kilo (projects in one panel). + The agent reads and writes across folders; every git surface stays anchored to one repo. +

+
+
+
+ 3 +
+

Multi-repo workspace — N checkouts, per-repo git surfaces

+

+ DevSwarm, alone, via a submodule bundle. This is the tier the PRD is + aiming at and where our cut 2 lands. +

+
+
+
+ 4 +
+

Coordinated fan-out — N agents, one per repo, N PRs

+

+ Factory Missions: "run coordinated changes across dozens of + repositories… decompose large projects into independent subtasks and assign each to a + dedicated Droid." Orthogonal to a shared workspace — it's the answer when the change + is mechanical and repeats across many repos, not when one feature threads through three. + Worth knowing we could reach it later by pointing automations at a project. +

+
+
+
+
+ +
+

The matrix

+

Seven capabilities across tiers 2 and 3, and where our v0 lands.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CapabilityVS Codetier 2Cursortier 2Codextier 2Conductortier 2Kilotier 2Devintier 1Capytier 1Factorytier 4DevSwarmtier 3Superset v0
Project owns a repo listdefine once, inherit per workspace◐hand-kept .code-workspace◐inherited●Source folders + Primary○◐projects in a panel○○App access scope, not a workspace○no public evidence●submodule bundle●over local projects
One workspace spans N repos●●●○N workspaces, then link○separate projects●multi-root virtual FS○one task, one repo◐"staged checkout" claimed by a review, not their docs●●
No two workspaces share a checkoutoutcome, not mechanism — worktree on a device, the sandbox itself in cloud○○○primary only; secondaries shared○links live worktrees◐per project, not per workspace●session VM●"its own VM… no shared filesystem"●sandboxed per droid●●
Same branch name where freeconvenience, not a constraint — see open questions——○secondaries have no branch○○——one repo per task○◐own branches, name never stated◐best effort, bump per repo
Agent reaches all repos with no per-folder permission setup●all roots open●◐sandbox roots; open bug #35260○/add-dir per session○●——●●repos are children of cwd
Per-repo diff in the UI●SCM view groups changes by repository●inherited SCM view○primary cwd only○other repo's own workspace◐VS Code's SCM, not Agent Manager○no evidence—○no evidence●"unified file list… filter to a member"◐cut 2 — PR-1
Ship surface knows about N reposyou pick which to open PRs for◐commit per repo; PRs need an extension◐inherited○"commit each independently"○○○no evidence—●Missions ship multiple PRs — by fan-out●own PRs, merge independently◐cut 2 — PR-2
+
+
+ ● ships it + ◐ partial / planned + ○ doesn't + — not applicable at that tier + scroll sideways → +
+

+ The row that changed the conclusion is VS Code's. Its Source Control view has + grouped changes by repository in a multi-root workspace for years, with independent commits per + repo — so "per-repo diff" is not novel, and Cursor and Kilo inherit it for free by being VS Code. + What none of the ten has is the combination: a project-owned repo list, isolation per + workspace, per-repo diffs and a per-repo ship surface in one product. Every individual capability + exists somewhere; the assembly doesn't. +

+
+ +
+

Tool by tool

+

What each one actually does, in their words.

+
+ +
+

Conductor

links workspaces
+

one repo per workspace · /add-dir

+
"Add each directory to Conductor… Create a workspace for each directory… In one workspace, enter /add-dir and select the other workspaces you'd like to link together."
+

+ Workspaces live at ~/conductor/workspaces/<repo name>/<workspace name> + — one worktree, one branch, one diff viewer. Multi-repo is Claude Code's own + --add-dir flag surfaced as a slash command. Their recommended answer to + multi-repo is actually "use a monorepo": "Conductor supports monorepos out of the box." +

+

Gap: N sidebar rows per task, uncoordinated branch names, edits to a linked + repo land in that other workspace's diff viewer — and per Anthropic's docs, + --add-dir loads none of the linked repo's CLAUDE.md by default.

+
+ +
+

Cursor

multi-root editor
+

VS Code .code-workspace · Apr 2026

+
"A single agent session can now target a reusable workspace made of multiple folders."
+

+ Inherited straight from VS Code: a workspace file lists folder roots, indexing runs + per root, @Codebase spans them. Genuinely good for context. Git stays + per-folder and the workspace file is yours to maintain by hand. +

+

Gap: no project-level definition, no isolation, no git story beyond + what VS Code already gave you.

+
+ +
+

Codex

primary-folder anchor
+

multi-folder projects · 23 Jul 2026, build 26.715

+
"New chats, Git operations, and discovery of AGENTS.md, skills and config.toml use the primary folder; secondary folders remain available for file search, reading, and editing."
+

+ The closest to our design: a project is an ordered folder list with one marked + Primary, and the primary owns every git surface. Secondaries are plain + existing directories — not worktrees — shared across every thread, with .git/ + read-only and the diff panel scoped to the primary cwd. +

+

Gap: worktree mode forks the primary only, so two parallel threads edit + the same secondary directory. #35260 + — hand-made sibling worktrees don't even reach the sandbox's writable roots. Open, no reply.

+
+ +
+

Kilo

projects in a panel
+

Multi-project Agent Manager · experimental, off by default

+
"Each repository has its own worktrees, sessions, sections, selection and state… so sessions and worktrees are not mixed between repositories."
+

+ Explicitly not a unified workspace — several repos shown as separate projects in + one panel, each with state in its own .kilo/agent-manager.json. A maintainer + has filed to formally forbid pointing it at a non-git parent folder, because users keep + trying and Agent Manager mishandles it. +

+

Gap: grouping, not unification. One agent never spans two repos.

+
+ +
+

Devin

indexing + grouping
+

multi-root virtual FS · thinnest evidence of the six

+

+ Their changelog confirms "support for working on multi-root workspaces containing virtual + filesystem folders," plus bulk repo indexing and sessions groupable by target repository. + The visible investment is in knowing about many repos, not in checking several + out together. +

+

Unknown: no public documentation of a project repo list, shared branch or + per-repo ship flow — treat the empty cells as unevidenced, not as confirmed absences.

+
+ +
+

DevSwarm

closest to the ambition
+

git submodules + worktrees

+
"Every repository inside a Multi-Repo is backed by Git submodules… each member is checked out as a Git worktree that shares the underlying repo's object store instead of creating another full clone."
+

+ The only product with the full shape, and the mechanics are specific enough to believe: + "each repository keeps its own history, its own remote, its own branches, its own pull + requests." The review surface is real too — "the unified file list shows every + repo's diff in one place. Filter to the api member to scrutinize the contract + change on its own." +

+

Caveat: vendor documentation, not verified hands-on — and whether all members + share one branch name is never stated. Submodules impose a parent repo and ongoing SHA + maintenance, which is the tax we should be able to avoid.

+
+ +
+
+ +
+

Why they all stop

+

The hard part isn't the checkouts. It's everything keyed by one workspace.

+ +

+ Cutting N worktrees is easy. What's expensive is that a single-repo product accumulates + state keyed by workspace, on the safe assumption that a workspace is one repo — and + that the agent quietly assumes it too. Five such places were found: three in our + code, two in Claude Code's documented behaviour. The last two are the ones the PRD doesn't + mention and are the reason the container folder isn't free. +

+ +
+
+ 1 +
+

The host-service status cache would serve the wrong repo's diff

+

+ Keyed Map<workspaceId, Map<baseBranch, Variant>>. Three repos in + one workspace usually share a base branch name, so they collide on one key and the last + snapshot wins. Silent wrong data, not a slowdown. +

+

+ Scope, now verified: this is the host-service git router, which + serves remote and cloud workspaces. The desktop's own Changes surface is a separate, + fully path-based router — worktreePath: z.string(), validated against a + registered-worktrees table, cached by makeStatusCacheKey(worktreePath, branch). + For a local workspace the switcher never touches this cache at all. +

+

git-status-store.ts:31 · desktop changes/status.ts:25

+
+
+
+ 2 +
+

The refresh queue would serialize the repos

+

+ Map<workspaceId, WorkspaceQueue> with one active slot and a FIFO + queue — three repos refresh one after another. Fixed by the same change as above: + key on workspaceId + repoFolder. +

+

git-status-refresh-limiter.ts:31

+
+
+
+ 3 +
+

Only one checkout would be watched

+

+ One { workspaceId, worktreePath, watcher } per workspace, attached from + the row's single path. Without N watcher pairs plus refcounting, edits in the other + repos never mark the workspace dirty. The fiddliest of the four. +

+

git-watcher.ts

+
+
+
+ 4 +
+

Every repo's AGENTS.md would go lazy

+

+ Anthropic's docs, verbatim: "Claude also discovers CLAUDE.md and CLAUDE.local.md files + in subdirectories under your current working directory. Instead of loading them at launch, + they are included when Claude reads files in those subdirectories." +

+

+ So opening the agent at a container folder demotes each repo's instructions from eager to + lazy — on this repo, that means starting without the i18n, comments and plugin rules until + something happens to touch a file. Codex has the identical problem and documents it. + Conductor's /add-dir route is worse still: those directories' CLAUDE.md files + are not loaded at all unless CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD + is set. +

+

mitigation: WS-6 root manifest, now mandatory rather than nice-to-have

+
+
+
+ 5 +
+

Auto memory would fragment per workspace

+

+ Also from Anthropic's docs: the auto-memory directory "is derived from the git + repository, so all worktrees and subdirectories within the same repo share one auto memory + directory. Outside a git repo, the project root is used instead." +

+

+ A container folder is not a git repo, and its path is per-workspace. So every multi-repo + workspace would get its own empty memory directory instead of sharing the repo's — and + lose it on delete. Today every worktree of a repo shares one. This is a silent regression + in agent quality that no test would catch. +

+

mitigation: set CLAUDE_CODE_PROJECT_DIR_NAME at agent launch (needs Claude Code ≥ v2.1.234)

+
+
+
+ +

+ The good news is the diff plumbing itself is contained. Every host-service git procedure + resolves its path through one 31-line function, called from 24 sites — 21 of + them in a single file — all shaped identically. A repo switcher is an optional second argument, + not a rewrite. That matters because redefining the workspace path itself would touch + 1,756 non-test references, and refusing that one change is what keeps + everything else cheap. +

+
+ +
+

Where we land

+

Two anchors instead of one is the whole trick.

+ +

+ Codex anchors git at the primary folder because it has one anchor to spend. Kilo's + maintainer wants to forbid non-git parent folders for the same reason: their workspace root + doubles as the git root, so pointing it at a container breaks worktrees and setup scripts. + Splitting the two fields dissolves the tension. +

+ +
~/.superset/worktrees/<primaryProjectId>/avi/multi-repo/   ← rootPath: agent, terminal, file tree
+  superset/       .git → file into base repo   branch: avi/multi-repo   base: main   ← worktreePath
+  skills/         .git → file into base repo   branch: avi/multi-repo   base: main
+  homebrew-tap/   .git → file into base repo   branch: avi/multi-repo   base: master
+ +
+
+

worktreePath

+
git anchor
+
    +
  • Always a real repository checkout — Kilo's objection never applies
  • +
  • Stays the primary, so all 1,702 references keep working untouched
  • +
  • Changes, Ship, PR status, status refresh: unchanged in v0
  • +
+
+
+

rootPath

+
human + agent anchor
+
    +
  • The container folder the person opens
  • +
  • Repos are children of cwd, so no --add-dir, no sandbox roots
  • +
  • Never passed to git, setup scripts or worktree operations — one-line invariant, cheap test
  • +
+
+
+ +
+

+ The resulting position: Codex's UX and its cheap git story, plus the + per-repo isolation Codex and Conductor both lack, plus a real branch in every repo — which + turns per-repo PRs from a redesign into an increment. +

+

+ Individual developers keep hand-rolling this container as the "meta-repo" or + "repo-of-repos" pattern because it's the only way to get an agent that sees everything. + No product ships it. That gap is the opening. +

+
+
+ +
+

What v0 is

+

Host-side only. No cloud schema change at all.

+ +

+ superset ws create --project already takes local project ids, and a local + project already means "one repo checked out at one path." So the Source-folders dialog is a + picker over things we already have — which deletes the most expensive requirements in the PRD + outright. +

+ +
+
+

In

+
~8 of 62 requirements
+
    +
  • Source-folders list on the project, first is Primary (Codex's dialog, simplified)
  • +
  • One checkout per repo, isolated per workspace — worktrees on a device, the sandbox itself in cloud (WS-1)
  • +
  • Same branch name in each repo where it's free, bumped per repo where it isn't (WS-2, downgraded — see open questions)
  • +
  • Atomic create with rollback (WS-5)
  • +
  • Agent context kit: root manifest + SUPERSET_WORKSPACE_REPOS + CLAUDE_CODE_PROJECT_DIR_NAME (WS-6 — mandatory, see landmines 4 & 5)
  • +
  • Delete removes every worktree, then the root (WS-9)
  • +
  • Missing repos cloned on first use (WS-4)
  • +
  • Repeatable --project, repos array in ws get (CLI-2, CLI-4)
  • +
+
+
+

Out

+
deferred or deleted
+
    +
  • GitHub App Add-repositories dialog (PRJ-2 — deleted, not deferred)
  • +
  • Per-repository installation tokens (SEC-1 — folders are already cloned)
  • +
  • project_repositories in Postgres + migration (MIG-1)
  • +
  • Cloud sandboxes, environments, warm snapshots (all of §8.3)
  • +
  • Sidebar grouping by project (WS-8 — falls back to primary)
  • +
  • Per-repo setup scripts (WS-7)
  • +
+
+
+ +

Then, immediately: the part only DevSwarm has

+

+ PR-1 (repo switcher on Changes) and PR-2 (one PR per repo + against its own base). Both the Changes view and the file tree hang off a single variable each + — ChangesContent.tsx:22 and FilesView.tsx:149 — so the client side is + small. +

+

+ The open question from v1 of this page is now answered, and it's good news: the desktop's + changes router is path-based end to end, keyed by + worktreePath and validated against a registered-worktrees table. So for local + workspaces, cut 2 is "register N worktree paths, pass the selected one" and landmines 1–2 + never fire. They still have to be paid for remote and cloud workspaces served by the + host-service router, and landmine 3 (one watcher per workspace) is unavoidable either way. +

+
+ +
+

The verdict

+

Three cuts. The agent-context work is not optional in any of them.

+ +
+

+ Build the host-side primitive first and the cloud project side last — the + inverse of the PRD's milestone order. The PRD builds the web project flow (M3) first, which + front-loads the GitHub App dialog, a Postgres table and a migration. None of that is needed + to make multiple repositories feel native, because ws create --project already + takes local project ids. +

+
+ +

Cut 1 — v0, days not weeks

+

+ Source-folders list over local projects with a Primary. One isolated checkout per repo, + atomic create, delete-all. rootPath opens the agent and the file tree; + worktreePath stays the primary and owns every git surface, so all 1,756 references + keep working. Plus the agent context kit — root manifest, SUPERSET_WORKSPACE_REPOS, + CLAUDE_CODE_PROJECT_DIR_NAME — which landmines 4 and 5 promoted from polish to + prerequisite. Ships strictly ahead of Conductor and Codex on isolation, level on everything else. +

+ +

Cut 2 — the differentiator

+

+ Repo switcher on Changes, then a Ship surface that lists each repo with commits ahead and opens + a PR for the ones you check — against that repo's own base. Not "one action fires N PRs": + you pick. Cheap for local workspaces now that the desktop router is confirmed path-based; the + host-service cache and limiter get keyed by workspaceId + repoFolder for remote, + and the watcher becomes N pairs. Note this is not novel UX — VS Code has grouped changes by + repository for years, so the pattern is well-trodden and the risk is low. What's novel is having + it together with a project-owned list and per-workspace isolation. Cross-linked PR bodies + and workspace-name titles (PR-3) are the opinionated part and stay out until someone asks. +

+ +

Cut 3 — cloud

+

+ Exactly as the PRD stages it (§8.3, ENV-*). No competitive pressure here — not one of the six + ties multi-repo to a cloud environment model, so this is ours to schedule. +

+ +

Four things to explicitly not do

+
    +
  • Don't redefine worktreePath as the container. 1,756 references, and it buys nothing cut 1 doesn't already give.
  • +
  • Don't use submodules. DevSwarm's bundle works but imposes a parent repo and SHA maintenance; a project-owned repo list gets the same result with no git-level coupling.
  • +
  • Don't build the GitHub App repo dialog for v0. PRJ-2 and SEC-1 are deleted by the local-projects picker, not deferred by it.
  • +
  • Never pass rootPath to git, setup scripts or worktree operations. One-line invariant, one cheap test — and the exact conflation a Kilo maintainer has an open issue to prevent.
  • +
+ +

The one thing to check before committing

+

+ A DevSwarm trial. Everything above assumes we can reach their capability without their + submodule tax. That assumption is load-bearing for cut 2 and it has not been tested. +

+ +

Open, from review

+

+ Three challenges from Avi on v2, two of which moved the plan: +

+
    +
  • + Worktrees shouldn't be enforced regardless of environment. Agreed, and the + matrix row is reworded to say so. The requirement is the outcome — no two workspaces + share a checkout — not the mechanism. On a device a worktree delivers it; in a cloud sandbox + the sandbox already is the boundary, so plain clones under /workspace are correct + and cheaper. The PRD already splits it this way (ENV-3); v1 of this page flattened it. +
  • +
  • + Is one branch name across repos necessary? No. Downgraded from a constraint + to a convenience. WS-2's "if the name is taken in any repo, bump it in all" + needs a probe of every member before create and produces a surprising rename; per-repo bumping + gives matching names almost always and breaks nothing when it doesn't. Cross-links live in PR + bodies, not in a branch-name search, so nothing downstream depends on it. +
  • +
  • + "One PR per repo from one action" feels opinionated. Fair, and it was my + phrasing overselling it — the PRD's PR-2 was already opt-in per repo. Reworded to "Ship + surface knows about N repos; you pick which to open." The genuinely opinionated pieces + (cross-linked bodies, workspace-name titles) are now explicitly out of scope. +
  • +
+
+ +
+

Evidence quality

+

+ Every quote on this page was re-checked against its primary source for v2. What changed: +

+
    +
  • + One quote was wrong and is gone. v1 attributed "A workspace is tied to + exactly one repository" to Conductor. That sentence does not appear in their documentation — + it was a summarisation artifact. The substance holds and is now carried by their actual + words ("create a workspace for each directory… then /add-dir") plus the + repo-keyed workspace path. +
  • +
  • + Two numbers were wrong and are corrected. 33 resolveWorktreePath + "call sites" was really 33 references including imports and type positions; there are + 24 actual calls. And worktreePath non-test references are + 1,756, not 1,702 — v1 used a filter that also dropped lines merely containing + the word "test". +
  • +
  • + DevSwarm was under-claimed. v1 marked their per-repo diff "claimed, not + verified". Their own documentation is specific and mechanical — submodules, worktrees sharing + an object store, a unified file list filterable per member — so the row is upgraded. Still + vendor documentation, still no hands-on trial. +
  • +
  • + Verified verbatim: the Cursor sentence, both Kilo sentences, the Codex + release note (build 26.715, 23 Jul 2026), and the Conductor three-step. Codex's read-only + .git/ is corroborated by several independent openai/codex issues and is a + general sandbox behaviour, not specific to secondary folders. +
  • +
  • + Devin remains the thinnest row. Multi-root support is confirmed from their + changelog; everything else is absence of evidence, and the matrix now says so rather than + scoring it. +
  • +
+

+ Highest-value remaining check: 30 minutes in a DevSwarm trial. They are the + only competitor with the capability this plan treats as the differentiator, and whether their + submodule bundle is load-bearing or incidental changes how confident we should be that we can + beat it without one. +

+
+ +
+

Sources

+ +
+ +
+ Companion to Multi-repo projects PRD (docs/prd/multi-repo-projects.md). The PRD + defines the destination and a 33-requirement MVP; this page argues for a smaller first cut and a + different build order — host-side primitive first, cloud project side last. Pin a comment on any + element to push back. +
+ +
+ + diff --git a/docs/pages/multi-repo-implementation.html b/docs/pages/multi-repo-implementation.html new file mode 100644 index 00000000000..a75d8501c65 --- /dev/null +++ b/docs/pages/multi-repo-implementation.html @@ -0,0 +1,996 @@ + + + + + +Multi-repo projects: the implementation + + + + +
+
+
Implementation · multi-repo projects · v1
+

Codex's model, with one difference
that changes what it's worth.

+

+ A project becomes a primary folder plus any number of additional folders. Codex ships exactly + this and gives the primary a worktree, leaving the rest as shared directories two threads can + trample. We give every folder its own checkout — the difference between "the agent + can see your other repo" and "this workspace owns its own copy of all three." +

+

+ The requirement is that outcome, not the mechanism. On a device a git worktree + delivers it. In a cloud sandbox the sandbox is already the isolation boundary, so plain clones + under /workspace are correct there and worktrees buy + nothing. This page is the device cut; read "worktree" below as "the device mechanism for one + isolated checkout." +

+
+ 18 Sep 2026 + Device-only · behind a flag + Companion: the competitive scan +
+
+
+ +
+ +
+

The shape

+

One container, N checkouts, one branch name, git anchored on the primary.

+ +

On a device — worktrees

+
~/.superset/worktrees/<primaryProjectId>/avi/multi-repo/      ← the container
+  superset/        worktree · branch avi/multi-repo · base main    ← primary · agent cwd · workspaces.worktreePath
+  skills/          worktree · branch avi/multi-repo · base main
+  homebrew-tap/    worktree · branch avi/multi-repo · base master
+ +

+ Each folder is a real git worktree cut from its own base repo, so their + .git files point into three unrelated repositories and none of them knows the + others exist. The container is a plain directory with no git in it. Nothing is a submodule and + there is no parent repo. +

+ +

In a cloud sandbox — the folders, on a branch

+
/workspace/                                                   ← the container, one sandbox per workspace
+  superset/        clone · checked out on avi/multi-repo          ← primary · agent cwd
+  skills/          clone · checked out on avi/multi-repo
+  homebrew-tap/    clone · checked out on avi/multi-repo
+ +

+ Same container, same folders, same branch name — and no worktree anywhere. Put each repo in + its folder and git checkout -b. The next section is why that is not a shortcut but + the correct answer, and what drops out of the build because of it. +

+ +
+

+ The load-bearing decision: workspaces.worktreePath keeps pointing + at the primary checkout, not the container — a worktree on a device, a clone in a + sandbox. Either way it is still a git checkout, so every one of the 1,756 + non-test references to it keeps working untouched: Changes, Ship, PR status, status refresh, + delete. Multi-repo becomes additive instead of a rewrite. +

+
+
+ +
+

Local and cloud

+

One model, two provisioning paths — and the cloud one is much shorter.

+ +

+ git worktree solves a problem only a device has: many working copies of one + repo on one machine. A sandbox holds exactly one workspace and therefore exactly one + working copy per repo, so the feature has no job there. Using it anyway would mean inventing a + base repo to cut worktrees from, purely to preserve a symmetry nothing benefits from. +

+ +
+

+ Everything above provisioning is shared, and that is what makes the split + safe: the container with N folders, the folder list copied at creation, one branch name, the + primary at position 0 owning the git surfaces, workspace_repos, the host-service + rows the sandbox seeds for itself, the folder picker, and the agent launch. The two paths + differ only in how a folder becomes a working copy. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepDeviceCloud sandbox
Get the repoMatch an existing local project by link, then by remote; otherwise clone to ~/.superset/repos/<owner>/<name> and create the local projectgit clone straight into /workspace/<folder>. A warm snapshot that already carries the repo shallow-fetches instead
Make the working copygit worktree add from the base repo into the containerNothing — the clone is the working copy
Get on the branchPart of worktree add -b, after checking the name is not already held by another worktree of that repogit checkout -b in the folder
Branch collisionsReal. The name may exist, or be held by another worktree — including a stale registration whose directory is gone, which is why creation prunes firstNone locally. The sandbox is fresh, so only the remote can collide, and that surfaces at push
DeleteDirty-check each folder, delete the branches, git worktree remove each, remove the container, keep each base repo's worktree list honestDestroy the sandbox
Failure modes to handleStale worktree registrations, "branch is already used by worktree at <missing-path>", partial rollback across N folders, the rm -rf guard resolving against the right project idClone failures on a cold start. That is the list
DiskCheap — worktrees share the base repo's object store, so N folders is not N clonesN full clones unless the snapshot is warm
+
+ +

What this means for the build

+

+ Read the table right to left and the useful conclusion falls out: almost every hard part + of this plan is device-only. Atomic rollback across N folders, the branch-collision + pass, the delete saga and its safety guard, resolving folders to existing local projects — none + of it exists in the cloud path. The device path is carrying the complexity, and it is the one we + are building first anyway. +

+

+ So cloud should land as a genuinely small addition on a shared model, not a second + implementation. The thing to protect is that split: one branch, in one step + (step 4 below), and no "is cloud" flag threaded through resolution, branch naming, registration, + rollback or delete. If that flag starts spreading, the two paths have begun to diverge and the + shared model is no longer real. +

+ +
+

+ The one thing cloud makes harder, not easier. Worktrees make N folders nearly + free on disk; clones do not. A three-repo workspace in a sandbox is three full checkouts plus + three sets of dependencies, against a writable disk that is half the environment's memory. + That is why environment size, warm snapshots and a cap on folders per project are cloud + concerns — on a device they barely register. +

+
+
+ +
+

Where we diverge from Codex

+

They mount folders. We check them out.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 Codex multi-folder projectsThis plan
Primary folderGit root; drives chats, git ops, AGENTS.md discoverySame
Additional foldersExisting directories mounted as-is, .git/ read-onlyAn isolated checkout each, on the workspace's branch — worktree on a device, clone in a sandbox
Parallel workTwo threads edit the same secondary directory and trample each otherEvery workspace owns its checkouts; nothing is shared
Branch in a secondaryNone — whatever the folder happened to be onThe workspace's branch, so commits land somewhere meaningful
Shipping a secondary"Each repository must be committed independently", outside the appA real branch exists, so per-repo PRs become an increment, not a redesign
+
+ +

+ That last row is the point. Codex's secondaries have nowhere for a commit to go, so per-repo + pull requests would need a redesign before they could even start. Ours have a branch from day + one, which is what makes the Ship surface a later increment rather than a different product. +

+
+ +
+

Data model

+

What the mapping is today, and the smallest change that makes it a list.

+ +

How a project maps to a repo today

+

+ There is no mapping table. The mapping is a column, and it lives in three + places that are mid-migration — which is the single most important thing to know before adding + anything. +

+ +
+ + + + + + + + + + + + + + + + + + + + + +
StoreThe mappingStatus
projects
host.db, per machine
repo_path — one repo checked out at one path, NOT NULL. repo_owner, repo_name, repo_url, remote_name are denormalised from the git remote at write time. workspaces.project_id points hereThe authority. This is what ws create --project resolves and what every git surface reaches through
projects + worktrees
local-db, desktop renderer
main_repo_path plus github_owner, with one worktrees row per checkout underneathThe v1 store. Being migrated away from, paced by the V1_AUTO_MIGRATION flag
v2_projects
cloud Postgres, org-level
repo_clone_url, plus github_repository_id into github_repositoriesBeing removed. v2_workspaces.project_id already dropped its FK "ahead of the v2_projects table removal", and the one remaining reader is described in the code as "the last consumer… it dies with the v1 importer"
+
+ +

And there is no link column between them

+

+ Nothing on a host project stores a cloud project id. When the two have to be associated, it + happens by matching the git remote URL at lookup time through + v2Project.findByGitHubRemote — and even that only runs on the v1 importer path. + Meanwhile v2_workspaces.project_id is now written with the host-side local + project id, which is exactly why its foreign key could be dropped. +

+ +
+

+ Which settles where the folder list goes. An earlier version of this page put + it in the cloud, hanging off v2_projects. That was wrong — you cannot add a table + to one that is being deleted. The project definition has already moved host-side, so the + folder list follows it: project_folders in host.db, beside the + table that already owns repo_path. +

+

+ This makes the plan simpler, not harder. Multi-repo becomes entirely host-side + — no Postgres migration, no cloud schema change — which is consistent with + ws create --project already taking a local project id. +

+
+ +

Host — the project's folder list

+

+ A project keeps its own columns and gains rows. An existing project gets exactly one row + mirroring repo_path, so a single-repo project is a multi-repo project with N=1. +

+
project_folders (new, host.db)
+  project_id      → projects.id, cascade
+  position        0 is the primary
+  folder          the directory name inside the container
+  repo_path       the base repo on this machine, once resolved
+  base_branch
+  unique(project_id, position) · unique(project_id, folder)
+

+ The existing projects.repo_path and its parsed remote columns keep mirroring + position 0 through the transition, so every reader that has not been taught about lists carries + on working. +

+ +

Host — which checkouts a workspace has

+
workspace_repos (new, host.db)
+  workspace_id    → workspaces.id, cascade
+  position        0 is the primary
+  project_id      → the local project this checkout belongs to
+  folder, worktree_path, branch, base_branch
+  unique(workspace_id, position) · unique(workspace_id, project_id)
+
+workspaces (existing)
+  + root_path        nullable · the container. NULL = today's single-repo workspace
+  worktreePath    unchanged — still the primary checkout
+ +
+

A prior-art note, not a shortcut

+

+ local-db's worktrees table is already + { id, projectId, path, branch, baseBranch, gitStatus, … } — one row per checkout, + keyed by project, with no assumption that a workspace has only one. Worth reading as + precedent for the workspace_repos shape. +

+

+ It is not something we can build on, though: that table belongs to the v1 + desktop store this repo is migrating away from. An earlier draft of this page claimed the + folder picker got its registry and its path validation from it for free. It does not. +

+

packages/local-db/src/schema/schema.ts:70

+
+
+ +
+

Projects v2, behind a flag

+

A new creation path. The old one keeps working untouched.

+ +

+ The flag is a PostHog gate registered in FEATURE_FLAGS + (packages/shared/src/constants.ts:105) and read with + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS), matching how + CLOUD_ACCESS and V1_AUTO_MIGRATION already work. +

+

+ "v2" here means the second version of the project shape, and is unrelated to the + v1→v2 desktop migration V1_AUTO_MIGRATION paces. Worth picking different words + before this ships — the collision will cost someone an afternoon otherwise. +

+ +

What the flag actually gates

+
    +
  • On: the project form shows a Source folders list — add a folder, remove one, mark one primary. Workspaces created in a project with 2+ folders get the multi-repo path.
  • +
  • Off: the form is exactly what it is today. One folder, no list, no picker.
  • +
+ +

Backward compatibility, stated as rules

+
    +
  1. One folder is not a special case, it is the general case with N=1. A project with a single folder produces a workspace with root_path = NULL and one workspace_repos row whose worktree_path equals worktreePath. Byte-for-byte what exists today.
  2. +
  3. No folder moves on disk. Existing workspaces are never restructured into a container. Only workspaces created after the flag, in a project with 2+ folders, get one.
  4. +
  5. Old clients ignore the new fields. root_path and the repos array are additive; a pre-change desktop reads worktreePath and behaves as before, which is why the primary had to stay there.
  6. +
  7. The migration is a backfill, not a rewrite. One workspace_repos row per existing workspace, idempotent, derived from columns that already exist.
  8. +
+ +
+

+ The invariant to enforce with a test: root_path is never passed + to git, to a setup script, or to a worktree operation. It is a display and launch path only. + Kilo has an open issue from a maintainer asking for exactly this guard, because their + workspace root doubles as their git root and pointing it at a non-git folder breaks worktrees + and setup scripts. Our two fields keep that impossible — as long as nothing crosses them. +

+
+
+ +
+

Creating the workspace

+

A loop, a shared branch name, and a rollback that leaves nothing behind.

+ +
    +
  1. +

    Resolve the folder list

    +

    + Copy the project's folders onto the workspace at creation time. Editing the project later + changes future workspaces only; a live workspace never changes shape. +

    +
  2. +
  3. +

    Resolve each folder to a local project — device only

    +

    + Match by explicit link, then by remote URL, otherwise clone to + ~/.superset/repos/<owner>/<name> and create the local project. Progress + streams into the workspace's setup terminal so a cold first run is visible rather than a hang. +

    +

    + A sandbox skips this entirely — there are no pre-existing local projects to match against, + so the clone in step 4 is both the fetch and the working copy. +

    +
  4. +
  5. +

    Pick the branch name

    +

    + Use the same name in every repo where it is free, and bump only the repo where it is taken. + Names match in the ordinary case; nothing breaks when they don't. Cross-links live in PR + bodies, so nothing downstream depends on the names being identical. +

    +
  6. +
  7. +

    Create the container, then N checkouts

    +

    + mkdir the container, then materialise each folder, primary first. Base branch + comes from each folder's row, so homebrew-tap can fork from + master while the others fork from main. +

    +

    + This is the only step that differs by environment, and it should stay that way. + A device runs git worktree add -b against the local base repo. A sandbox runs + git clone then git checkout -b — or a shallow fetch, against a warm + snapshot. Everything before and after is identical, so confining the branch here is what + stops the two paths diverging later. +

    +
  8. +
  9. +

    Register and persist

    +

    + Insert the workspaces row with root_path set, N + workspace_repos rows, and N rows in the desktop's worktrees registry + so the Changes pane will accept each path. +

    +
  10. +
  11. +

    Roll back as a unit

    +

    + If any worktree fails, remove the ones already created, delete the container, and surface an + error naming the folder that failed. A half-built multi-repo workspace is worse than none. +

    +
  12. +
+ +

Delete is the same loop, reversed — on a device

+

+ List the folders, refuse if any is dirty and name it, apply "delete branch" to all of them, + git worktree remove each, then remove the container. The existing + rm -rf safety guard checks a path is inside the project's managed worktrees root — + it needs to take the primary's project id, since the secondaries' worktrees live under + the primary's tree and would otherwise fail the check and silently refuse to clean up. +

+

+ In a sandbox none of this exists. Destroying the sandbox destroys every checkout at once: no + dirty gate, no per-folder removal, no container cleanup, no base-repo worktree lists left to + keep honest. The dirty check is still worth offering as a warning before destroy, but it is a + courtesy there rather than a correctness requirement. +

+
+ +
+

Launching the agent

+

In the primary, not the container — and that decision pays for itself.

+ +

+ The agent's cwd is the primary worktree. The spawn procedure already takes an + optional cwd (terminal-agents.ts:545), so this is a value, not new + plumbing. Two things fall out, and they are the reason this is the right call: +

+ +
+
+

The primary's instructions load eagerly

+
landmine avoided
+
    +
  • Claude Code loads CLAUDE.md from cwd and every directory above it at launch
  • +
  • Files in subdirectories load only when it reads inside them
  • +
  • cwd = primary means the primary's CLAUDE.md is in context from the first token
  • +
+
+
+

Auto memory stays where it belongs

+
landmine avoided
+
    +
  • The memory directory is derived from the git repository; outside a git repo it falls back to the project root
  • +
  • A container is not a git repo and its path is per-workspace — so launching there would give every workspace its own empty memory, discarded on delete
  • +
  • cwd = primary keeps one memory directory shared across all worktrees of that repo, as today
  • +
+
+
+ +
+

+ The cost, stated plainly: the other folders are now siblings of cwd, + not children, so the agent cannot reach them by default. Launch has to pass each secondary as + an additional directory, and Anthropic's docs are explicit that those directories' CLAUDE.md + files are not loaded unless + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 is set — so we set it. +

+

+ This is a trade, not a free win. Launching at the container would have made the folders + children and skipped the flag entirely, at the price of both landmines above. Fragmented auto + memory is the worse outcome: it degrades agent quality silently and no test would catch it. + Worth knowing that additional-directory permission handling has rough edges in practice — + claude-code#36682 — so + this deserves a real end-to-end check, not an assumption. +

+
+ +

What the workspace tells the agent

+

+ The creation message says the workspace spans several folders rather than leaving the agent to + discover it. Alongside it, a manifest at <root>/.superset/workspace.json and + SUPERSET_WORKSPACE_REPOS in the environment list the folders, their repos and their + branches. One trap worth naming in that message: git status at the container fails, + because the container is not a repo. The agent has to be in a folder. +

+
+ +
+

The folder picker

+

One control, re-roots the pane below it.

+ +
+ 📁  sample-test  ▾
+  
+ 📁  evidence
+ 📁  sample-test  ✓
+  
+ ├─ test-same/
+ ├─ tree/
+ ├─ README.md M
+ └─ tree.zip +
+ +

+ A dropdown at the top of the right-hand pane listing the workspace's folders with a check on the + active one, exactly as in Codex. Selecting a folder re-roots the file tree and the Changes list + below it. Dirty counts per folder belong on the rows so you can see where the work is without + opening each one. +

+ +

The client side is small

+

+ The file tree derives its root from a single expression — + const rootPath = workspaceQuery.data?.worktreePath ?? "" in the v2 + FilesTab. The picker replaces that one expression with the selected folder's path. +

+

v2-workspace/$workspaceId/.../FilesTab/FilesTab.tsx:92

+ +

The server side is not

+

+ The v2 workspace surface reads git through + workspaceTrpc.git.getStatus({ workspaceId }) — the host-service router, keyed by + workspace id. So the picker needs, for every workspace and not only remote ones: +

+
    +
  • An optional repo argument on the git procedures, resolved through the one 31-line resolveWorktreePath that all 24 call sites share. Omitting it keeps returning the primary, so nothing existing changes.
  • +
  • The status store re-keyed from workspaceId → baseBranch to include the folder. Three repos in one workspace usually share a base branch name, so today they would collide on one key and the last snapshot would win — showing one repo's diff under another's name.
  • +
  • The refresh limiter re-keyed the same way, or the repos serialise behind one active slot.
  • +
  • N file watchers per workspace instead of one, with refcounting, or edits outside the primary never mark the workspace dirty.
  • +
+ +
+

+ Correcting an earlier version of this page. It claimed the desktop's + changes router is path-based end to end and that the picker was therefore nearly + free for local workspaces. That router is path-based — but it belongs to the v1 + surface. The v2 workspace, which is the one being built on, goes through the host-service git + router keyed by workspace id. The re-keying work above is required for local workspaces too, + not deferred with cloud. +

+
+ +

State

+

+ The selected folder is per workspace, which is entity-keyed with unbounded cardinality — so per + the desktop's persisted-state policy it belongs in host-side SQLite, not localStorage. +

+
+ +
+

Blast radius

+

Every surface that assumes a workspace is one repo.

+ +

+ The map below is the workspace window. Blue is an existing element that changes meaning; green + dashed is new; grey is untouched. The numbers key to the table underneath. +

+ +
+
+ Workspace header + 2 PR chips — one per folder, aggregate check status + 3 Branch & dirty indicator — whose branch, whose dirt? +
+ +
+ Sidebar + 1 Workspace row — repo-count badge, filter matches any member + Project groups — unchanged in v1 +
+ +
+ Main pane + 4 Terminal / agent +

+ cwd is the primary. Panes, tabs and the editor are untouched — they already take an + absolute path. +

+
+ +
+ Right pane + 5 Folder picker — NEW + 6 Files — rooted at selection + 7 Changes — diff of selection + 8 Ship — one row per folder +
+
+ +
+ changes meaning + new + untouched +
+ +

What changes, surface by surface

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 SurfaceTodayChange
1Sidebar workspace rowOne row, one repo implied by its projectRepo-count badge; filtering by any member repo matches. Grouping stays under the primary
2PR chips & check statusOne chip from workspaces.pull_request_idOne chip per folder, read from the join table; aggregate status is the worst of the members
3Header branch / dirtyThe workspace's one branch and its dirtMirrors the selected folder, or shows an aggregate. Open question — see below
4Terminal, agent, panesOpen at worktreePathcwd is the primary; secondaries passed as additional directories. Panes and the editor are untouched — they already take absolute paths
5Folder picker—New control; selection persists per workspace in host-side SQLite
6Files tabrootPath = workspace.worktreePathOne expression — root at the selected folder
7Changesgit.getStatus({ workspaceId }){ workspaceId, repo }. Pulls in the store re-key, the limiter re-key and N watchers
8Ship / Create PRcanCreatePr = workspace.projectId != null — one button, one PROne row per folder with commits ahead, each opening a PR against its own base
+
+ +

Yes — one PR per folder, and the schema is already there

+
+

+ workspace_pull_requests is already a many-to-many join, primary-keyed on + (workspace_id, pull_request_id), and its own comment says "a workspace that + opens a PR per branch accumulates one row each." Each pull_requests row + already carries project_id, repo_owner and repo_name, + so a PR already knows which repo it belongs to. +

+

+ So per-folder PRs need no schema change at all. + workspaces.pull_request_id stays what it is — the single "currently linked" + pointer the legacy sidebar reads — and keeps pointing at the primary's PR. + What changes is the UI that reads the join instead of the pointer, and + createForWorkspace taking a folder so it resolves the right checkout and base. +

+
+ +

Server-side, where the real work is

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WhatChangeWhere
Path resolutionOptional repo argument; omitted keeps returning the primary, so nothing existing movesresolveWorktreePath — 31 lines, 24 call sites, 21 of them in git.ts
Status cacheKey on the folder as well. Without it, repos sharing a base branch name collide and one repo's diff appears under another'sgit-status-store.ts:31
Refresh limiterKey on the folder, or the repos serialise behind one active slotgit-status-refresh-limiter.ts:31
File watcherN watcher pairs per workspace with refcounting, or edits outside the primary never mark it dirtyevents/git-watcher.ts
CreationThe loop, shared branch name, atomic rollbackworkspaces.ts:607
DeletePer-folder dirty gate and removal; the rm -rf guard must resolve against the primary's project id or it refuses to clean up secondariesworkspace-cleanup.ts:508,588
PR creationFolder argument; base branch from that folder's rowpull-requests/procedures/create-for-workspace.ts:40
Project CRUDproject_folders read/write, reorder, set-primarytrpc/router/project/
+
+ +

Surfaces that do not change

+

+ Worth stating, because it is most of the app. Panes, tabs, the editor, the terminal, file + read/write, attachments and search all take an absolute path already and never + ask which repo it came from. Automations need nothing: they target a project, and the project + now carries the list. The cloud database is untouched in v1. +

+ +

The decision this forces

+
+

+ Is "the workspace" singular or plural in the header? Item 3 is the one place + the model leaks into a surface that has no obvious answer. The branch name and dirty indicator + are workspace-level today. With N folders they can either follow the picker — making the + header a view of the selected folder — or aggregate, showing a count and the worst status. +

+

+ Following the picker is more consistent and much cheaper. Aggregating tells you more at a + glance but invents a summary the git model has no concept of. Worth deciding before the picker + is built, because it determines whether the selection is pane-local state or workspace-level + state. +

+
+
+ +
+

Build order

+

Four steps, each shippable, each useful on its own.

+ +
    +
  1. +

    The host primitive

    +

    + workspace_repos, root_path, the creation loop, atomic rollback, + delete-all, and the backfill. Driven by a repeatable --project on the CLI and + a repos array in ws get. No UI at all. Dogfoodable from a terminal on day one. +

    +
  2. +
  3. +

    Project v2 behind the flag

    +

    + project_folders, the Source folders list in the project form, and the + workspace composer summarising the folders it will check out. +

    +
  4. +
  5. +

    Agent context

    +

    + Primary as cwd, secondaries as additional directories, + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD, the manifest, the environment + variable, and the creation message. Verified end to end against a real agent, because the + permission behaviour here is the part most likely to surprise us. +

    +
  6. +
  7. +

    The folder picker

    +

    + Changes and Files re-rooted by selection, with per-folder dirty counts. The client change is + one expression per pane; the real work is server-side and applies to every workspace — an + optional repo argument through resolveWorktreePath, the status + store and refresh limiter re-keyed to include the folder, and N file watchers instead of + one. This is the largest of the four steps. +

    +
  8. +
+
+ +
+

Not in v1

+
+
+

Deferred

+
wanted, later
+
    +
  • Per-repo Ship and one PR per repo — the branch exists, so this is an increment
  • +
  • Cross-linked PR bodies and workspace-name titles
  • +
  • Cloud sandboxes: they keep cloning one repo for now. The shape is already designed for them (see The shape) — it is the clone loop and the seed step that are unbuilt, not the model
  • +
  • Sidebar grouping by project; a multi-repo workspace files under its primary
  • +
  • Per-repo setup scripts
  • +
+
+
+

Deleted, not deferred

+
the picker over local projects removes these
+
    +
  • A GitHub App "Add repositories" dialog with search, avatars and access states
  • +
  • Per-repository installation tokens — the folders are already cloned on the machine
  • +
  • Merged "all repositories" diff. One folder at a time, by selection
  • +
  • Per-folder branch names inside one workspace
  • +
+
+
+
+ +
+

Open questions

+
    +
  1. Cap on folders per project. Eight is the PRD's number, chosen for sandbox disk and creation time. Nothing enforces it structurally and raising it is one line — but a cap that exists is easier to raise than one that never existed.
  2. +
  3. Does the picker belong in the header too? The right pane is the obvious home. Whether the workspace header should mirror the selection, or stay workspace-level, decides whether "which folder am I in" is a pane concept or a workspace concept.
  4. +
  5. What happens when a folder's repo is removed from the project? Existing workspaces keep it, per the PRD. Worth confirming that reads as intended rather than as a bug when the folder is still on disk.
  6. +
  7. Additional-directory permissions. Whether a secondary worktree is writable by the agent without per-call approval is the one behaviour here that has burned another vendor. This is a thing to verify early, not late.
  8. +
+
+ +
+ Companion to Multi-repo workspaces: the competitive scan, which covers what ten + other tools do here and why the worktree-per-folder difference is where the value is. The PRD + (docs/prd/multi-repo-projects.md) remains the source of truth for requirement ids; + this page is the device-only v1 cut of it. Pin a comment on any element to push back. +
+ +
+ + diff --git a/packages/cli/src/commands/workspaces/create/command.test.ts b/packages/cli/src/commands/workspaces/create/command.test.ts index 940f33ac895..027bf1bb13f 100644 --- a/packages/cli/src/commands/workspaces/create/command.test.ts +++ b/packages/cli/src/commands/workspaces/create/command.test.ts @@ -64,7 +64,7 @@ function invoke( prompt?: string; effort?: string; tag?: string[]; - project?: string | undefined; + project?: string[] | undefined; session?: boolean; local?: boolean; branch?: string | undefined; @@ -81,7 +81,7 @@ function invoke( args: {} as never, options: { local: true, - project: "project-1", + project: ["project-1"], name: "agent-effort", branch: "agent/effort", ...overrides, diff --git a/packages/cli/src/commands/workspaces/create/command.ts b/packages/cli/src/commands/workspaces/create/command.ts index d7de15b93ce..deccc1116f1 100644 --- a/packages/cli/src/commands/workspaces/create/command.ts +++ b/packages/cli/src/commands/workspaces/create/command.ts @@ -14,9 +14,11 @@ export default command({ environment: string().desc( "Environment a cloud workspace starts from (id or name; defaults to the first with repositories)", ), - project: string().desc( - "Project ID, for a workspace on a host. Required with --local or --host unless --session", - ), + project: string() + .variadic() + .desc( + "Project ID, for a workspace on a host. Required with --local or --host unless --session. Repeatable: the first is the workspace's primary repository and each further one is checked out beside it in the same workspace folder", + ), session: boolean().desc( "Create a project-less session (a managed scratch folder) on a host. Cannot be combined with --project", ), @@ -88,7 +90,7 @@ export default command({ ); } - const projectId = options.project; + const [projectId, ...extraProjectIds] = options.project ?? []; if (options.session && projectId !== undefined) { throw new CLIError("--session cannot be combined with --project"); } @@ -106,6 +108,12 @@ export default command({ "Use --checkout worktree or --checkout local", ); } + if (extraProjectIds.length > 0 && checkout === "local") { + throw new CLIError( + "--checkout local takes a single --project", + "A local workspace shares the project's own checkout, which is one repository", + ); + } if (isSession && options.checkout !== undefined) { throw new CLIError( "--checkout requires --project", @@ -239,6 +247,7 @@ export default command({ : target.client.workspaces.create; const result = await create.mutate({ projectId, + ...(extraProjectIds.length > 0 ? { extraProjectIds } : {}), name: options.name, ...(checkout === "local" ? { checkout } : {}), branch: options.branch, diff --git a/packages/cli/src/commands/workspaces/create/createCloudWorkspace.ts b/packages/cli/src/commands/workspaces/create/createCloudWorkspace.ts index 184786ac14f..c132fe21b04 100644 --- a/packages/cli/src/commands/workspaces/create/createCloudWorkspace.ts +++ b/packages/cli/src/commands/workspaces/create/createCloudWorkspace.ts @@ -13,7 +13,7 @@ export interface CloudCreateOptions { model?: string; effort?: string; environment?: string; - project?: string; + project?: string[]; session?: boolean; pr?: number; task?: string; @@ -39,7 +39,7 @@ export async function createCloudWorkspace(args: { // or host. Those flags mean the caller wanted a host, so say how. for (const [flag, value] of [ ["--checkout", options.checkout], - ["--project", options.project], + ["--project", options.project?.length ? options.project : undefined], ["--session", options.session || undefined], ["--pr", options.pr], ["--task", options.task], diff --git a/packages/cli/src/commands/workspaces/get/command.ts b/packages/cli/src/commands/workspaces/get/command.ts index 22d8e7929d8..1995aa3890d 100644 --- a/packages/cli/src/commands/workspaces/get/command.ts +++ b/packages/cli/src/commands/workspaces/get/command.ts @@ -53,13 +53,26 @@ export default command({ const value = (detail as Record)[options.field]; return { data: detail, - message: value === null || value === undefined ? "" : String(value), + message: + value === null || value === undefined + ? "" + : Array.isArray(value) + ? JSON.stringify(value) + : String(value), }; } const width = Math.max(...Object.keys(detail).map((key) => key.length)); const message = Object.entries(detail) .map(([key, value]) => { + if (key === "repos") { + const repos = value as HostDetail["repos"]; + const lines = repos.map( + (repo) => + `${" ".repeat(width + 2)}${repo.folder} ${repo.repository ?? "—"} ${repo.branch} (base ${repo.base ?? "—"}) ${repo.path}`, + ); + return [`${key.padEnd(width)} ${repos.length}`, ...lines].join("\n"); + } const shown = value === null || value === undefined ? "—" : value; return `${key.padEnd(width)} ${shown}`; }) @@ -70,6 +83,7 @@ export default command({ }); type Ctx = Parameters[0]["run"]>[0]["ctx"]; +type HostDetail = Awaited>; /** A cloud workspace's details are the API's row; reading them never wakes its sandbox. */ async function cloudDetail(ctx: Ctx, organizationId: string, id: string) { @@ -127,6 +141,7 @@ async function hostDetail( taskId: workspace.taskId, worktreePath: workspace.worktreePath, worktreeExists: workspace.worktreeExists, + repos: workspace.repos ?? [], createdAt: workspace.createdAt, }; } diff --git a/packages/host-service/drizzle/0036_sweet_king_bedlam.sql b/packages/host-service/drizzle/0036_sweet_king_bedlam.sql new file mode 100644 index 00000000000..00c0f734aff --- /dev/null +++ b/packages/host-service/drizzle/0036_sweet_king_bedlam.sql @@ -0,0 +1,33 @@ +CREATE TABLE `project_folders` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `position` integer NOT NULL, + `folder` text NOT NULL, + `repo_path` text, + `repo_url` text, + `base_branch` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `project_folders_project_position_unique` ON `project_folders` (`project_id`,`position`);--> statement-breakpoint +CREATE UNIQUE INDEX `project_folders_project_folder_unique` ON `project_folders` (`project_id`,`folder`);--> statement-breakpoint +CREATE TABLE `workspace_repos` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `position` integer NOT NULL, + `project_id` text NOT NULL, + `folder` text NOT NULL, + `worktree_path` text NOT NULL, + `branch` text NOT NULL, + `base_branch` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `workspace_repos_workspace_position_unique` ON `workspace_repos` (`workspace_id`,`position`);--> statement-breakpoint +CREATE UNIQUE INDEX `workspace_repos_workspace_project_unique` ON `workspace_repos` (`workspace_id`,`project_id`);--> statement-breakpoint +CREATE INDEX `workspace_repos_project_id_idx` ON `workspace_repos` (`project_id`);--> statement-breakpoint +ALTER TABLE `workspaces` ADD `root_path` text; \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0036_snapshot.json b/packages/host-service/drizzle/meta/0036_snapshot.json new file mode 100644 index 00000000000..332118d47c6 --- /dev/null +++ b/packages/host-service/drizzle/meta/0036_snapshot.json @@ -0,0 +1,1396 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b631ce9a-f184-4a79-a0b4-89343a9ed1c5", + "prevId": "1cb7970e-1036-48bc-a9b8-72d93b11443b", + "tables": { + "host_agent_configs": { + "name": "host_agent_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preset_id": { + "name": "preset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_id": { + "name": "icon_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "args_json": { + "name": "args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "prompt_transport": { + "name": "prompt_transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_args_json": { + "name": "prompt_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "resume_args_json": { + "name": "resume_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "fork_args_json": { + "name": "fork_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "env_json": { + "name": "env_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_agent_configs_display_order_idx": { + "name": "host_agent_configs_display_order_idx", + "columns": [ + "display_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_settings": { + "name": "host_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_claude_config_dir": { + "name": "default_claude_config_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_codex_home": { + "name": "default_codex_home", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_folders": { + "name": "project_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_folders_project_position_unique": { + "name": "project_folders_project_position_unique", + "columns": [ + "project_id", + "position" + ], + "isUnique": true + }, + "project_folders_project_folder_unique": { + "name": "project_folders_project_folder_unique", + "columns": [ + "project_id", + "folder" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_folders_project_id_projects_id_fk": { + "name": "project_folders_project_id_projects_id_fk", + "tableFrom": "project_folders", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sparse_checkout_paths": { + "name": "sparse_checkout_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "naming_instructions": { + "name": "naming_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "merged_at": { + "name": "merged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag_folder_settings": { + "name": "tag_folder_settings", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tag_folder_settings_scope_tag_created_by_user_id_pk": { + "columns": [ + "scope", + "tag", + "created_by_user_id" + ], + "name": "tag_folder_settings_scope_tag_created_by_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_agent_bindings": { + "name": "terminal_agent_bindings", + "columns": { + "terminal_id": { + "name": "terminal_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_type": { + "name": "last_event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resumed_into_terminal_id": { + "name": "resumed_into_terminal_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_agent_bindings_workspace_id_idx": { + "name": "terminal_agent_bindings_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk": { + "name": "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk", + "tableFrom": "terminal_agent_bindings", + "tableTo": "terminal_sessions", + "columnsFrom": [ + "terminal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_workspace_id": { + "name": "origin_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispose_requested_at": { + "name": "dispose_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", + "columns": [ + "origin_workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "origin_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_pull_requests": { + "name": "workspace_pull_requests", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_pull_requests_workspace_idx": { + "name": "workspace_pull_requests_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_pull_requests_workspace_id_workspaces_id_fk": { + "name": "workspace_pull_requests_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_pull_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_pull_requests_pull_request_id_pull_requests_id_fk": { + "name": "workspace_pull_requests_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspace_pull_requests", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_pull_requests_workspace_id_pull_request_id_pk": { + "columns": [ + "workspace_id", + "pull_request_id" + ], + "name": "workspace_pull_requests_workspace_id_pull_request_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_repos": { + "name": "workspace_repos", + "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 + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_repos_workspace_position_unique": { + "name": "workspace_repos_workspace_position_unique", + "columns": [ + "workspace_id", + "position" + ], + "isUnique": true + }, + "workspace_repos_workspace_project_unique": { + "name": "workspace_repos_workspace_project_unique", + "columns": [ + "workspace_id", + "project_id" + ], + "isUnique": true + }, + "workspace_repos_project_id_idx": { + "name": "workspace_repos_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_repos_workspace_id_workspaces_id_fk": { + "name": "workspace_repos_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_repos", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_repos_project_id_projects_id_fk": { + "name": "workspace_repos_project_id_projects_id_fk", + "tableFrom": "workspace_repos", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_tags": { + "name": "workspace_tags", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_tags_tag_idx": { + "name": "workspace_tags_tag_idx", + "columns": [ + "tag" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_tags_workspace_id_workspaces_id_fk": { + "name": "workspace_tags_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_tags", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_tags_workspace_id_tag_created_by_user_id_pk": { + "columns": [ + "workspace_id", + "tag", + "created_by_user_id" + ], + "name": "workspace_tags_workspace_id_tag_created_by_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "root_path": { + "name": "root_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_owner": { + "name": "upstream_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_repo": { + "name": "upstream_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_branch": { + "name": "upstream_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suppressed_pull_request_id": { + "name": "suppressed_pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'worktree'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archive_reason": { + "name": "archive_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_archived_at_idx": { + "name": "workspaces_archived_at_idx", + "columns": [ + "archived_at" + ], + "isUnique": false + }, + "workspaces_upstream_ref_idx": { + "name": "workspaces_upstream_ref_idx", + "columns": [ + "upstream_owner", + "upstream_repo", + "upstream_branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_suppressed_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_suppressed_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "suppressed_pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index 2727b33f014..c025957b265 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -253,6 +253,13 @@ "when": 1789230415434, "tag": "0035_local_workspaces", "breakpoints": true + }, + { + "idx": 36, + "version": "6", + "when": 1789746564470, + "tag": "0036_sweet_king_bedlam", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index a45bff20f00..96022744114 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -26,6 +26,7 @@ import { registerBrowserCdpRoute } from "./runtime/browser-bridge/browser-cdp-ro import { WorkspaceFilesystemManager } from "./runtime/filesystem"; import type { GitCredentialProvider } from "./runtime/git"; import { createGitEnvResolver, createGitFactory } from "./runtime/git"; +import { runMultiRepoBackfill } from "./runtime/multi-repo-backfill"; import { runProjectBackfill } from "./runtime/project-backfill"; import { PullRequestRuntimeManager } from "./runtime/pull-requests"; import { @@ -281,6 +282,18 @@ export function createApp(options: CreateAppOptions): CreateAppResult { }).catch((err) => { console.warn("[host-service] project backfill failed:", err); }); + // Runs after the project backfill so a freshly-named project still + // derives its folder name from the repo directory. + try { + const filled = runMultiRepoBackfill({ db }); + if (filled.folders > 0 || filled.repos > 0) { + console.log( + `[multi-repo-backfill] backfilled ${filled.folders} folder(s) and ${filled.repos} repo row(s)`, + ); + } + } catch (err) { + console.warn("[host-service] multi-repo backfill failed:", err); + } // Finish any delete the previous process crashed out of (archived row // whose worktree still exists). await runArchivedWorkspaceReconcile({ diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index d593f0b832c..76adf5b4a21 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -119,6 +119,44 @@ export const projects = sqliteTable( (table) => [index("projects_repo_path_idx").on(table.repoPath)], ); +/** + * The ordered list of repositories a project owns. Position 0 is the primary, + * mirrored onto `projects.repo_path` and the parsed remote columns so every + * single-repo surface keeps reading the row it always did. + */ +export const projectFolders = sqliteTable( + "project_folders", + { + id: text().primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + position: integer().notNull(), + folder: text().notNull(), + // Null until the folder is resolved to a checkout on this host. + repoPath: text("repo_path"), + // Where to clone from while `repo_path` is still null. + repoUrl: text("repo_url"), + baseBranch: text("base_branch"), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + updatedAt: integer("updated_at") + .notNull() + .$defaultFn(() => Date.now()), + }, + (table) => [ + uniqueIndex("project_folders_project_position_unique").on( + table.projectId, + table.position, + ), + uniqueIndex("project_folders_project_folder_unique").on( + table.projectId, + table.folder, + ), + ], +); + /** * Single-row host-wide settings (always `id = 1`). The host-service has no * generic settings store yet; this row holds host-wide knobs (worktree base @@ -229,6 +267,11 @@ export const workspaces = sqliteTable( onDelete: "cascade", }), worktreePath: text("worktree_path").notNull(), + // The container directory holding one worktree per project folder, NULL + // for a single-repo workspace. Not a git checkout: never hand it to + // git, setup scripts, or worktree operations — `worktree_path` always + // names the primary worktree and is what those want. + rootPath: text("root_path"), branch: text().notNull(), headSha: text("head_sha"), upstreamOwner: text("upstream_owner"), @@ -287,6 +330,47 @@ export const workspaces = sqliteTable( ], ); +/** + * One checked-out repository per workspace, mirroring the project's folder + * list at creation time. Position 0 is the primary, and its `worktree_path` + * equals `workspaces.worktree_path`. + * + * `project_id` is the local project the folder resolved to — for a secondary + * that is a DIFFERENT project from `workspaces.project_id`, yet its worktree + * still lives under the primary project's managed root. + */ +export const workspaceRepos = sqliteTable( + "workspace_repos", + { + id: text().primaryKey(), + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + position: integer().notNull(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + folder: text().notNull(), + worktreePath: text("worktree_path").notNull(), + branch: text().notNull(), + baseBranch: text("base_branch"), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + }, + (table) => [ + uniqueIndex("workspace_repos_workspace_position_unique").on( + table.workspaceId, + table.position, + ), + uniqueIndex("workspace_repos_workspace_project_unique").on( + table.workspaceId, + table.projectId, + ), + index("workspace_repos_project_id_idx").on(table.projectId), + ], +); + /** * Host-local presentation for a tag folder. A row exists only once someone * customises the folder (same lifecycle as the old local row), beside the diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index 4765edeaee4..90edf936efd 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -98,7 +98,10 @@ export interface WorkspaceSnapshot { name: string; branch: string; type: "local" | "worktree" | "session"; + /** Always the primary checkout, whether or not the workspace has more. */ worktreePath: string; + /** The multi-repo container, null otherwise. Never a git checkout. */ + rootPath: string | null; taskId: string | null; createdByUserId: string | null; createdAt: number; diff --git a/packages/host-service/src/no-main-loop-blocking.test.ts b/packages/host-service/src/no-main-loop-blocking.test.ts index ce23c25b52a..d376a6d99b4 100644 --- a/packages/host-service/src/no-main-loop-blocking.test.ts +++ b/packages/host-service/src/no-main-loop-blocking.test.ts @@ -77,6 +77,7 @@ const RULES: Rule[] = [ "trpc/router/workspace-creation/procedures/search-branches.ts": 1, "trpc/router/workspace-creation/utils/ai-workspace-names.ts": 1, "trpc/router/workspace-creation/utils/list-branch-names.ts": 1, + "trpc/router/workspace-creation/shared/multi-repo/create-worktrees.ts": 2, "trpc/router/workspace/workspace.ts": 1, "trpc/router/workspaces/workspaces.ts": 1, }, diff --git a/packages/host-service/src/projects/project-folders.test.ts b/packages/host-service/src/projects/project-folders.test.ts new file mode 100644 index 00000000000..2bda2c0f5d3 --- /dev/null +++ b/packages/host-service/src/projects/project-folders.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + deduplicateFolderName, + defaultFolderNameForRepo, + sanitizeFolderName, +} from "./project-folders"; + +describe("sanitizeFolderName", () => { + test("accepts a plain directory name", () => { + expect(sanitizeFolderName(" api ")).toBe("api"); + expect(sanitizeFolderName("web-2")).toBe("web-2"); + expect(sanitizeFolderName("a.b_c")).toBe("a.b_c"); + }); + + test("rejects anything that is not one path segment", () => { + for (const input of [ + "", + ".", + "..", + "a/b", + "a\\b", + "-leading", + ".hidden", + "has space", + "x".repeat(65), + ]) { + expect(sanitizeFolderName(input)).toBeNull(); + } + }); +}); + +describe("deduplicateFolderName", () => { + test("returns the candidate when free", () => { + expect(deduplicateFolderName("api", ["web"])).toBe("api"); + }); + + test("suffixes past every taken name, case-insensitively", () => { + expect(deduplicateFolderName("api", ["API"])).toBe("api-2"); + expect(deduplicateFolderName("api", ["api", "api-2"])).toBe("api-3"); + }); +}); + +describe("defaultFolderNameForRepo", () => { + test("uses the directory name", () => { + expect(defaultFolderNameForRepo("/home/me/code/api")).toBe("api"); + }); + + test("slugs a directory name the folder rules would reject", () => { + expect(defaultFolderNameForRepo("/home/me/my project")).toBe("my-project"); + expect(defaultFolderNameForRepo("/home/me/.dotted")).toBe("dotted"); + }); +}); diff --git a/packages/host-service/src/projects/project-folders.ts b/packages/host-service/src/projects/project-folders.ts new file mode 100644 index 00000000000..62adf929f92 --- /dev/null +++ b/packages/host-service/src/projects/project-folders.ts @@ -0,0 +1,152 @@ +import { randomUUID } from "node:crypto"; +import { basename } from "node:path"; +import { eq } from "drizzle-orm"; +import type { HostDb } from "../db"; +import { projectFolders, projects } from "../db/schema"; + +export interface ProjectFolder { + /** Null for the synthesized primary of a project with no rows yet. */ + id: string | null; + projectId: string; + position: number; + folder: string; + repoPath: string | null; + repoUrl: string | null; + baseBranch: string | null; +} + +const MAX_FOLDER_NAME_LENGTH = 64; + +/** + * A folder name becomes a directory inside the workspace container, so it + * must be a single path segment with nothing a shell or a path join could + * reinterpret. Null when the input cannot be one. + */ +export function sanitizeFolderName(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed || trimmed.length > MAX_FOLDER_NAME_LENGTH) return null; + return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed) ? trimmed : null; +} + +/** "api", then "api-2", "api-3", … against the names already in use. */ +export function deduplicateFolderName( + candidate: string, + taken: Iterable, +): string { + const used = new Set([...taken].map((name) => name.toLowerCase())); + if (!used.has(candidate.toLowerCase())) return candidate; + for (let suffix = 2; ; suffix++) { + const next = `${candidate}-${suffix}`; + if (!used.has(next.toLowerCase())) return next; + } +} + +export function defaultFolderNameForRepo(repoPath: string): string { + const slug = basename(repoPath) + .trim() + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^[^A-Za-z0-9]+/, "") + .slice(0, MAX_FOLDER_NAME_LENGTH); + return sanitizeFolderName(slug) ?? "repo"; +} + +/** + * The project's folders in position order. A project with no rows yet reads + * as the single primary its `repo_path` already describes. + */ +export function listProjectFolders( + db: HostDb, + projectId: string, +): ProjectFolder[] { + const rows = db + .select() + .from(projectFolders) + .where(eq(projectFolders.projectId, projectId)) + .all() + .sort((a, b) => a.position - b.position); + if (rows.length > 0) { + return rows.map((row) => ({ + id: row.id, + projectId: row.projectId, + position: row.position, + folder: row.folder, + repoPath: row.repoPath, + repoUrl: row.repoUrl, + baseBranch: row.baseBranch, + })); + } + + const project = db + .select({ repoPath: projects.repoPath, repoUrl: projects.repoUrl }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) return []; + return [ + { + id: null, + projectId, + position: 0, + folder: defaultFolderNameForRepo(project.repoPath), + repoPath: project.repoPath, + repoUrl: project.repoUrl, + baseBranch: null, + }, + ]; +} + +/** Idempotent: gives a project that predates the table a real row to order. */ +export function ensurePrimaryProjectFolder( + db: HostDb, + projectId: string, +): ProjectFolder[] { + const existing = listProjectFolders(db, projectId); + if (existing.length === 0) return existing; + if (existing[0]?.id !== null) return existing; + + const now = Date.now(); + db.insert(projectFolders) + .values( + existing.map((folder) => ({ + id: randomUUID(), + projectId, + position: folder.position, + folder: folder.folder, + repoPath: folder.repoPath, + repoUrl: folder.repoUrl, + baseBranch: folder.baseBranch, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing() + .run(); + return listProjectFolders(db, projectId); +} + +/** + * Rewrite positions to 0..n-1 in the given order. SQLite enforces + * `unique(project_id, position)` per statement, so the rows are parked on + * negative positions first — a straight renumber collides mid-flight. + */ +export function reassignFolderPositions( + db: HostDb, + _projectId: string, + orderedFolderIds: string[], +): void { + const now = Date.now(); + db.transaction((tx) => { + orderedFolderIds.forEach((id, index) => { + tx.update(projectFolders) + .set({ position: -(index + 1), updatedAt: now }) + .where(eq(projectFolders.id, id)) + .run(); + }); + orderedFolderIds.forEach((id, index) => { + tx.update(projectFolders) + .set({ position: index, updatedAt: now }) + .where(eq(projectFolders.id, id)) + .run(); + }); + }); +} diff --git a/packages/host-service/src/runtime/multi-repo-backfill.ts b/packages/host-service/src/runtime/multi-repo-backfill.ts new file mode 100644 index 00000000000..d478b9a4f81 --- /dev/null +++ b/packages/host-service/src/runtime/multi-repo-backfill.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, isNotNull, notExists, sql } from "drizzle-orm"; +import type { HostDb } from "../db"; +import { + projectFolders, + projects, + workspaceRepos, + workspaces, +} from "../db/schema"; +import { defaultFolderNameForRepo } from "../projects/project-folders"; + +export interface MultiRepoBackfillContext { + db: HostDb; +} + +export interface MultiRepoBackfillResult { + folders: number; + repos: number; +} + +/** + * Materialize the position-0 rows every project and workspace implicitly had + * before they could own more than one repo. + * + * Idempotent and cheap in steady state: both passes select only rows with no + * row of their own, and the inserts tolerate a concurrent writer. + */ +export function runMultiRepoBackfill( + ctx: MultiRepoBackfillContext, +): MultiRepoBackfillResult { + const { db } = ctx; + const now = Date.now(); + + const projectsWithoutFolders = db + .select({ id: projects.id, repoPath: projects.repoPath }) + .from(projects) + .where( + notExists( + db + .select({ one: sql`1` }) + .from(projectFolders) + .where(eq(projectFolders.projectId, projects.id)), + ), + ) + .all(); + + if (projectsWithoutFolders.length > 0) { + db.insert(projectFolders) + .values( + projectsWithoutFolders.map((project) => ({ + id: randomUUID(), + projectId: project.id, + position: 0, + folder: defaultFolderNameForRepo(project.repoPath), + repoPath: project.repoPath, + baseBranch: null, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing() + .run(); + } + + const workspacesWithoutRepos = db + .select({ + id: workspaces.id, + projectId: workspaces.projectId, + worktreePath: workspaces.worktreePath, + branch: workspaces.branch, + }) + .from(workspaces) + .where( + and( + // `workspace_repos` keys on a project; a project-less session + // has none to key on. + isNotNull(workspaces.projectId), + notExists( + db + .select({ one: sql`1` }) + .from(workspaceRepos) + .where(eq(workspaceRepos.workspaceId, workspaces.id)), + ), + ), + ) + .all(); + + if (workspacesWithoutRepos.length === 0) { + return { folders: projectsWithoutFolders.length, repos: 0 }; + } + + const primaryFolderByProject = new Map( + db + .select({ + projectId: projectFolders.projectId, + folder: projectFolders.folder, + }) + .from(projectFolders) + .where(eq(projectFolders.position, 0)) + .all() + .map((row) => [row.projectId, row.folder]), + ); + + const rows = workspacesWithoutRepos.flatMap((workspace) => { + const projectId = workspace.projectId; + if (!projectId) return []; + const folder = primaryFolderByProject.get(projectId); + if (!folder) return []; + return [ + { + id: randomUUID(), + workspaceId: workspace.id, + position: 0, + projectId, + folder, + worktreePath: workspace.worktreePath, + branch: workspace.branch, + baseBranch: null, + createdAt: now, + }, + ]; + }); + + if (rows.length > 0) { + db.insert(workspaceRepos).values(rows).onConflictDoNothing().run(); + } + + return { folders: projectsWithoutFolders.length, repos: rows.length }; +} diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 3afb2cb8c20..7fab6735cdc 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -53,7 +53,10 @@ import { REVIEW_THREADS_QUERY, } from "./utils/graphql"; import { replyToReviewComment } from "./utils/reply-to-review-comment"; -import { resolveWorktreePath } from "./utils/resolve-worktree"; +import { + resolveWorktreePath, + resolveWorktreeTarget, +} from "./utils/resolve-worktree"; import { attachSpawnFailureDiagnostics } from "./utils/spawn-failure-diagnostics"; // Front-door cap for commit-file diffs. Statuses are admitted by @@ -133,17 +136,23 @@ function runStatusSnapshot( Pick, input: { workspaceId: string; + repo?: string; baseBranch?: string; priority?: "foreground" | "background"; }, ) { + const { worktreePath, repoKey } = resolveWorktreeTarget( + ctx, + input.workspaceId, + input.repo, + ); const requestKey = JSON.stringify({ baseBranch: input.baseBranch ?? null }); return gitStatusRefreshLimiter.run({ workspaceId: input.workspaceId, + repoKey, requestKey, priority: input.priority, run: async () => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); const workerPool = getHostWorkerPool(); @@ -176,6 +185,7 @@ function runStatusSnapshot( return gitStatusStore.read({ workspaceId: input.workspaceId, + repoKey, baseBranch: input.baseBranch ?? null, computeFull, computePartial: (paths) => @@ -209,8 +219,12 @@ function sumSnapshotDiffStats(snapshot: { return { additions, deletions, fileCount: byPath.size }; } -const getDiffInputShape = z.object({ +const workspaceRepoInput = z.object({ workspaceId: z.string(), + repo: z.string().optional(), +}); + +const getDiffInputShape = workspaceRepoInput.extend({ path: z.string(), category: z.enum(["against-base", "staged", "unstaged", "commit"]), baseBranch: z.string().optional(), @@ -226,8 +240,7 @@ const DIFF_SIDE_FILE_MAX_BYTES = 10 * 1024 * 1024; // A rename is two index entries (delete of `oldPath`, add of `filePath`); // staging or unstaging only one end would split it into a delete plus an add. -const stagingTargetInput = z.object({ - workspaceId: z.string(), +const stagingTargetInput = workspaceRepoInput.extend({ filePath: z.string(), oldPath: z.string().optional(), }); @@ -245,9 +258,13 @@ function resolveStagingTargetPaths( export const gitRouter = router({ listBranches: queryProcedure - .input(z.object({ workspaceId: z.string() })) + .input(workspaceRepoInput) .query(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); // `%(HEAD)` emits "*" for the checked-out branch, " " otherwise. @@ -282,8 +299,7 @@ export const gitRouter = router({ getStatus: queryProcedure .meta({ timeoutMs: 15_000 }) .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ baseBranch: z.string().optional(), priority: z.enum(["foreground", "background"]).optional(), }), @@ -359,13 +375,16 @@ export const gitRouter = router({ listCommits: queryProcedure .meta({ timeoutMs: 30_000 }) .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ baseBranch: z.string().optional(), }), ) .query(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); const base = await resolveBaseComparison(git, input.baseBranch); @@ -399,14 +418,17 @@ export const gitRouter = router({ getCommitFiles: queryProcedure .meta({ timeoutMs: 15_000 }) .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ commitHash: z.string(), fromHash: z.string().optional(), }), ) .query(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); const dedupeKey = `${input.workspaceId}:commit-files:${input.fromHash ?? ""}:${input.commitHash}`; const files = await runCommitFilesDeduped(dedupeKey, () => @@ -430,9 +452,13 @@ export const gitRouter = router({ }), getBaseBranch: queryProcedure - .input(z.object({ workspaceId: z.string() })) + .input(workspaceRepoInput) .query(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); const currentBranch = ( await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "") @@ -450,13 +476,16 @@ export const gitRouter = router({ setBaseBranch: protectedProcedure .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ baseBranch: z.string().nullable(), }), ) .mutation(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); const currentBranch = ( await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "") @@ -485,14 +514,17 @@ export const gitRouter = router({ renameBranch: protectedProcedure .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ oldName: z.string(), newName: z.string(), }), ) .mutation(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); // Check if branch has been pushed to remote @@ -521,14 +553,17 @@ export const gitRouter = router({ discardChanges: protectedProcedure .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ filePath: z.string(), }), ) .mutation(async ({ ctx, input }) => { assertSafeRelativePath(input.filePath); - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); const status = await git.status(); const isUntracked = status.not_added.includes(input.filePath); @@ -542,9 +577,13 @@ export const gitRouter = router({ }), discardAllUnstaged: protectedProcedure - .input(z.object({ workspaceId: z.string() })) + .input(workspaceRepoInput) .mutation(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); await git.raw(["checkout", "--", "."]); await git.raw(["clean", "-fd"]); @@ -553,9 +592,13 @@ export const gitRouter = router({ }), discardAllStaged: protectedProcedure - .input(z.object({ workspaceId: z.string() })) + .input(workspaceRepoInput) .mutation(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); const status = await git.status(); @@ -609,7 +652,11 @@ export const gitRouter = router({ .input(stagingTargetInput) .mutation(async ({ ctx, input }) => { const paths = resolveStagingTargetPaths(input); - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); const result = await getHostWorkerPool().run(gitStagePathsTask, { worktreePath, @@ -625,7 +672,11 @@ export const gitRouter = router({ .input(stagingTargetInput) .mutation(async ({ ctx, input }) => { const paths = resolveStagingTargetPaths(input); - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); const result = await getHostWorkerPool().run(gitStagePathsTask, { worktreePath, @@ -638,9 +689,13 @@ export const gitRouter = router({ }), stageAll: protectedProcedure - .input(z.object({ workspaceId: z.string() })) + .input(workspaceRepoInput) .mutation(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); await git.raw(["add", "-A"]); invalidateStatus(input.workspaceId); @@ -648,9 +703,13 @@ export const gitRouter = router({ }), unstageAll: protectedProcedure - .input(z.object({ workspaceId: z.string() })) + .input(workspaceRepoInput) .mutation(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); await git.raw(["reset", "HEAD"]); invalidateStatus(input.workspaceId); @@ -661,14 +720,17 @@ export const gitRouter = router({ // Commit hooks (lint-staged etc.) run here and can be slow. .meta({ timeoutMs: 60_000 }) .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ message: z.string().trim().min(1), stageAll: z.boolean().default(true), }), ) .mutation(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); const result = await getHostWorkerPool().run( gitCommitTask, @@ -692,9 +754,13 @@ export const gitRouter = router({ push: protectedProcedure .meta({ timeoutMs: 120_000 }) - .input(z.object({ workspaceId: z.string() })) + .input(workspaceRepoInput) .mutation(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); // The linked PR lookup stays on-loop (sync db reads); the git work // itself — upstream resolution and the push — runs in the pool. const workspace = ctx.db.query.workspaces @@ -742,7 +808,11 @@ export const gitRouter = router({ .input(getDiffInputShape) .query(async ({ ctx, input }) => { assertSafeRelativePath(input.path); - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); const refs = await resolveDiffCategoryRefs(git, input.category, input); return loadFileDiffContent( @@ -781,7 +851,11 @@ export const gitRouter = router({ "The unstaged new side is the working tree, not a git object", }); } - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); return getHostWorkerPool().run( gitDiffSideBlobTask, @@ -809,8 +883,7 @@ export const gitRouter = router({ getDiffBulk: queryProcedure .meta({ timeoutMs: 60_000 }) .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ paths: z.array(z.string()).min(1).max(MAX_DIFF_BULK_PATHS), category: z.enum(["against-base", "staged", "unstaged", "commit"]), baseBranch: z.string().optional(), @@ -820,7 +893,11 @@ export const gitRouter = router({ ) .query(async ({ ctx, input }) => { for (const path of input.paths) assertSafeRelativePath(path); - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); // Ref resolution and every file's `git show` pair run inside the // worker task, off the host-service event loop — see @@ -847,8 +924,7 @@ export const gitRouter = router({ getDiffPatch: queryProcedure .meta({ timeoutMs: 60_000 }) .input( - z.object({ - workspaceId: z.string(), + workspaceRepoInput.extend({ category: z.enum(["against-base", "staged", "unstaged", "commit"]), paths: z.array(z.string()).max(MAX_DIFF_BULK_PATHS).optional(), untrackedPaths: z.array(z.string()).max(MAX_DIFF_BULK_PATHS).optional(), @@ -861,7 +937,11 @@ export const gitRouter = router({ for (const path of input.paths ?? []) assertSafeRelativePath(path); for (const path of input.untrackedPaths ?? []) assertSafeRelativePath(path); - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); return getHostWorkerPool() .run( @@ -886,9 +966,13 @@ export const gitRouter = router({ getBranchSyncStatus: queryProcedure .meta({ timeoutMs: 30_000 }) - .input(z.object({ workspaceId: z.string() })) + .input(workspaceRepoInput) .query(async ({ ctx, input }) => { - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const git = await ctx.git(worktreePath); const currentBranch = ( diff --git a/packages/host-service/src/trpc/router/git/utils/git-status-refresh-limiter.ts b/packages/host-service/src/trpc/router/git/utils/git-status-refresh-limiter.ts index 385b9685db1..d59c3288b4e 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-status-refresh-limiter.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-status-refresh-limiter.ts @@ -10,7 +10,7 @@ interface ActiveTask { } interface QueuedTask { - workspaceId: string; + queueKey: string; requestKey: string; run: () => Promise; promise: Promise; @@ -21,14 +21,21 @@ interface QueuedTask { generation: number; } -interface WorkspaceQueue { +interface CheckoutQueue { active: ActiveTask | null; queued: QueuedTask[]; } +// Per checkout, not per workspace: two repos of one workspace routinely share +// a base branch name, and keyed on the workspace alone their refreshes would +// coalesce into one whose result answers for both. +function queueKeyFor(workspaceId: string, repoKey: string | undefined): string { + return `${workspaceId}\u0000${repoKey ?? ""}`; +} + export class GitStatusRefreshLimiter { private readonly concurrency: number; - private readonly workspaces = new Map(); + private readonly queues = new Map(); private readonly readyQueue: QueuedTask[] = []; private activeCount = 0; private sequence = 0; @@ -40,31 +47,32 @@ export class GitStatusRefreshLimiter { run({ workspaceId, + repoKey, requestKey, run, priority = "foreground", }: { workspaceId: string; + repoKey?: string; requestKey: string; run: () => Promise; priority?: GitStatusRefreshPriority; }): Promise { - const workspace = this.getWorkspaceQueue(workspaceId); + const queueKey = queueKeyFor(workspaceId, repoKey); + const queue = this.getQueue(queueKey); // Collapse repeated invalidations while a workspace refresh is active into // one trailing refresh per request key. That keeps the final snapshot fresh // without letting fs-event churn enqueue unbounded git subprocess work. - const queued = workspace.queued.find( - (task) => task.requestKey === requestKey, - ); + const queued = queue.queued.find((task) => task.requestKey === requestKey); if (queued) { this.promoteQueuedTask(queued, priority); return queued.promise as Promise; } - const task = this.createTask(workspaceId, requestKey, run, priority); - workspace.queued.push(task); - if (!workspace.active && workspace.queued[0] === task) { + const task = this.createTask(queueKey, requestKey, run, priority); + queue.queued.push(task); + if (!queue.active && queue.queued[0] === task) { this.readyQueue.push(task); this.pump(); } @@ -74,12 +82,12 @@ export class GitStatusRefreshLimiter { clear(): void { this.generation++; const queuedTasks = new Set(); - for (const workspace of this.workspaces.values()) { - for (const task of workspace.queued) { + for (const queue of this.queues.values()) { + for (const task of queue.queued) { queuedTasks.add(task); } } - this.workspaces.clear(); + this.queues.clear(); this.readyQueue.length = 0; this.activeCount = 0; for (const task of queuedTasks) { @@ -87,17 +95,17 @@ export class GitStatusRefreshLimiter { } } - private getWorkspaceQueue(workspaceId: string): WorkspaceQueue { - let workspace = this.workspaces.get(workspaceId); - if (!workspace) { - workspace = { active: null, queued: [] }; - this.workspaces.set(workspaceId, workspace); + private getQueue(queueKey: string): CheckoutQueue { + let queue = this.queues.get(queueKey); + if (!queue) { + queue = { active: null, queued: [] }; + this.queues.set(queueKey, queue); } - return workspace; + return queue; } private createTask( - workspaceId: string, + queueKey: string, requestKey: string, run: () => Promise, priority: GitStatusRefreshPriority, @@ -109,7 +117,7 @@ export class GitStatusRefreshLimiter { reject = rej; }); return { - workspaceId, + queueKey, requestKey, run, promise, @@ -137,12 +145,12 @@ export class GitStatusRefreshLimiter { if (!task) return; if (task.generation !== this.generation) continue; - const workspace = this.workspaces.get(task.workspaceId); - if (!workspace || workspace.active || workspace.queued[0] !== task) { + const queue = this.queues.get(task.queueKey); + if (!queue || queue.active || queue.queued[0] !== task) { continue; } - this.startTask(workspace, task); + this.startTask(queue, task); } } @@ -164,9 +172,9 @@ export class GitStatusRefreshLimiter { return bestTask; } - private startTask(workspace: WorkspaceQueue, task: QueuedTask): void { - workspace.queued.shift(); - workspace.active = { + private startTask(queue: CheckoutQueue, task: QueuedTask): void { + queue.queued.shift(); + queue.active = { requestKey: task.requestKey, promise: task.promise, }; @@ -178,15 +186,15 @@ export class GitStatusRefreshLimiter { .finally(() => { if (task.generation !== this.generation) return; this.activeCount--; - if (workspace.active?.promise === task.promise) { - workspace.active = null; + if (queue.active?.promise === task.promise) { + queue.active = null; } - if (workspace.queued.length > 0) { - const next = workspace.queued[0]; + if (queue.queued.length > 0) { + const next = queue.queued[0]; if (next) this.readyQueue.push(next); - } else if (!workspace.active) { - this.workspaces.delete(task.workspaceId); + } else if (!queue.active) { + this.queues.delete(task.queueKey); } this.pump(); diff --git a/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.adversarial.test.ts b/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.adversarial.test.ts index 2fba645adcb..e401f65302d 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.adversarial.test.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.adversarial.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { Branch, ChangedFile } from "../../types"; import type { GitStatusSnapshot } from "../git-status"; import type { GitStatusPartial } from "../git-status-partial"; -import { GitStatusStore } from "./git-status-store"; +import { GitStatusStore, variantKey } from "./git-status-store"; const BRANCH: Branch = { name: "main", @@ -522,8 +522,8 @@ describe("GitStatusStore keys and growth", () => { } const after = process.memoryUsage().heapUsed; // ...but the Changes-tab variant was never read again. - const main = variantsOf(store, "w").get("main"); - const sidebar = variantsOf(store, "w").get(""); + const main = variantsOf(store, "w").get(variantKey(undefined, "main")); + const sidebar = variantsOf(store, "w").get(variantKey(undefined, null)); expect(sidebar?.pending?.size).toBe(0); expect(main?.pending?.size).toBe(batches * perBatch); console.info( diff --git a/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.ts b/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.ts index 7e4652eb980..157f108ea2c 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.ts @@ -6,11 +6,15 @@ import { } from "../git-status-partial"; /** - * One cached snapshot per (workspace, baseBranch). Readers on the same + * One cached snapshot per (workspace, repo, baseBranch). Readers on the same * workspace routinely differ in base branch — the sidebar stats read with * none, the Changes tab with the configured one — and only the against-base * fields depend on it, so every variant is patched from the same change * stream instead of the variants evicting each other. + * + * The repo is part of the key because a multi-repo workspace's checkouts + * routinely share a base branch name (several on `main`): keyed on the base + * alone, one repo's diff would be served under another's name. */ interface Variant { cached: GitStatusSnapshot | null; @@ -22,11 +26,19 @@ interface Variant { interface ReadInput { workspaceId: string; + repoKey?: string; baseBranch: string | null; computeFull: () => Promise; computePartial: (paths: string[]) => Promise; } +export function variantKey( + repoKey: string | undefined, + baseBranch: string | null, +): string { + return `${repoKey ?? ""}\u0000${baseBranch ?? ""}`; +} + export class GitStatusStore { private readonly workspaces = new Map>(); @@ -56,7 +68,7 @@ export class GitStatusStore { const variants = this.workspaces.get(input.workspaceId); if (!variants) return input.computeFull(); - const key = input.baseBranch ?? ""; + const key = variantKey(input.repoKey, input.baseBranch); let variant = variants.get(key); if (!variant) { variant = { cached: null, pending: null, queue: Promise.resolve() }; diff --git a/packages/host-service/src/trpc/router/git/utils/resolve-worktree.ts b/packages/host-service/src/trpc/router/git/utils/resolve-worktree.ts index af59035ff9a..7d2518d2ea8 100644 --- a/packages/host-service/src/trpc/router/git/utils/resolve-worktree.ts +++ b/packages/host-service/src/trpc/router/git/utils/resolve-worktree.ts @@ -2,12 +2,28 @@ import { existsSync } from "node:fs"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { workspaces } from "../../../../db/schema"; +import { findWorkspaceRepo } from "../../../../workspaces/workspace-repos"; import type { protectedProcedure } from "../../../index"; -export function resolveWorktreePath( - ctx: Parameters[0]>[0]["ctx"], +type ProcedureCtx = Parameters< + Parameters[0] +>[0]["ctx"]; + +export interface WorktreeTarget { + worktreePath: string; + /** Cache-partition key for the repo; empty string for the primary. */ + repoKey: string; +} + +/** + * The worktree a `git.*` call operates in. `repo` names a folder (or a + * `workspace_repos` id) inside the workspace; omitting it means the primary. + */ +export function resolveWorktreeTarget( + ctx: ProcedureCtx, workspaceId: string, -): string { + repo?: string | null, +): WorktreeTarget { const workspace = ctx.db.query.workspaces .findFirst({ where: eq(workspaces.id, workspaceId) }) .sync(); @@ -17,15 +33,38 @@ export function resolveWorktreePath( message: "Workspace not found", }); } + + let worktreePath = workspace.worktreePath; + let repoKey = ""; + if (repo != null && repo !== "") { + const match = findWorkspaceRepo(ctx.db, workspaceId, repo); + if (!match) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace has no repo "${repo}"`, + }); + } + worktreePath = match.worktreePath; + repoKey = match.folder; + } + // A worktree deleted outside the app is a routine lifecycle state, not a // bug — classify it here so simple-git's construct error can't escape any // git.* procedure as a reportable 500. - if (!existsSync(workspace.worktreePath)) { + if (!existsSync(worktreePath)) { throw new TRPCError({ code: "NOT_FOUND", message: "Worktree no longer exists on disk", - cause: { kind: "WORKTREE_MISSING", worktreePath: workspace.worktreePath }, + cause: { kind: "WORKTREE_MISSING", worktreePath }, }); } - return workspace.worktreePath; + return { worktreePath, repoKey }; +} + +export function resolveWorktreePath( + ctx: ProcedureCtx, + workspaceId: string, + repo?: string | null, +): string { + return resolveWorktreeTarget(ctx, workspaceId, repo).worktreePath; } diff --git a/packages/host-service/src/trpc/router/project/folders/folders.ts b/packages/host-service/src/trpc/router/project/folders/folders.ts new file mode 100644 index 00000000000..7de3e044e7c --- /dev/null +++ b/packages/host-service/src/trpc/router/project/folders/folders.ts @@ -0,0 +1,304 @@ +import { randomUUID } from "node:crypto"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { projectFolders, projects } from "../../../../db/schema"; +import { + deduplicateFolderName, + defaultFolderNameForRepo, + ensurePrimaryProjectFolder, + listProjectFolders, + type ProjectFolder, + reassignFolderPositions, + sanitizeFolderName, +} from "../../../../projects/project-folders"; +import type { HostServiceContext } from "../../../../types"; +import { protectedProcedure, router } from "../../../index"; +import { resolveLocalRepo } from "../utils/resolve-repo"; + +function requireMaterializedFolders( + ctx: HostServiceContext, + projectId: string, +): ProjectFolder[] { + const folders = ensurePrimaryProjectFolder(ctx.db, projectId); + if (folders.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project is not set up on this host", + }); + } + return folders; +} + +function requireFolderId(folder: ProjectFolder): string { + if (folder.id === null) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Project folder was not materialized", + }); + } + return folder.id; +} + +function findFolder( + folders: ProjectFolder[], + folderId: string, +): ProjectFolder & { id: string } { + const match = folders.find((candidate) => candidate.id === folderId); + if (!match?.id) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Folder not found on this project: ${folderId}`, + }); + } + return match as ProjectFolder & { id: string }; +} + +function requireFolderName(input: string): string { + const sanitized = sanitizeFolderName(input); + if (!sanitized) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Folder must be a single directory name of letters, digits, dot, dash or underscore", + }); + } + return sanitized; +} + +async function mirrorPrimaryOntoProject( + ctx: HostServiceContext, + projectId: string, + folder: ProjectFolder, +): Promise { + if (!folder.repoPath) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Folder "${folder.folder}" has no checkout on this host yet and cannot be the primary`, + }); + } + const resolved = await resolveLocalRepo(folder.repoPath); + ctx.db + .update(projects) + .set({ + repoPath: resolved.repoPath, + repoProvider: resolved.parsed ? "github" : null, + repoOwner: resolved.parsed?.owner ?? null, + repoName: resolved.parsed?.name ?? null, + repoUrl: resolved.parsed?.url ?? null, + remoteName: resolved.remoteName, + updatedAt: Date.now(), + }) + .where(eq(projects.id, projectId)) + .run(); + if (resolved.repoPath !== folder.repoPath) { + ctx.db + .update(projectFolders) + .set({ repoPath: resolved.repoPath, updatedAt: Date.now() }) + .where(eq(projectFolders.id, folder.id ?? "")) + .run(); + } +} + +const folderShape = z.object({ + id: z.string(), + projectId: z.string(), + position: z.number(), + folder: z.string(), + repoPath: z.string().nullable(), + repoUrl: z.string().nullable(), + baseBranch: z.string().nullable(), +}); + +type FolderResponse = z.infer; + +function toResponse(folders: ProjectFolder[]): FolderResponse[] { + return folders.map((folder) => ({ + id: folder.id ?? "", + projectId: folder.projectId, + position: folder.position, + folder: folder.folder, + repoPath: folder.repoPath, + repoUrl: folder.repoUrl, + baseBranch: folder.baseBranch, + })); +} + +export const projectFoldersRouter = router({ + list: protectedProcedure + .input(z.object({ projectId: z.string().uuid() })) + .query(({ ctx, input }): { folders: FolderResponse[] } => { + const folders = listProjectFolders(ctx.db, input.projectId); + if (folders.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project is not set up on this host", + }); + } + return { folders: toResponse(folders) }; + }), + + /** + * `repoPath` links a checkout that already exists on this host; `repoUrl` + * alone leaves the clone to the first workspace that needs it. + */ + add: protectedProcedure + .input( + z + .object({ + projectId: z.string().uuid(), + repoPath: z.string().min(1).optional(), + repoUrl: z.string().min(1).optional(), + folder: z.string().min(1).optional(), + baseBranch: z.string().min(1).optional(), + }) + .refine((value) => value.repoPath || value.repoUrl, { + message: "Pass repoPath or repoUrl", + }), + ) + .mutation(async ({ ctx, input }): Promise<{ folder: FolderResponse }> => { + const folders = requireMaterializedFolders(ctx, input.projectId); + + const resolved = input.repoPath + ? await resolveLocalRepo(input.repoPath) + : null; + const parsedUrl = input.repoUrl ? parseGitHubRemote(input.repoUrl) : null; + if (input.repoUrl && !parsedUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Could not parse a repository from ${input.repoUrl}`, + }); + } + + const requested = input.folder + ? requireFolderName(input.folder) + : resolved + ? defaultFolderNameForRepo(resolved.repoPath) + : (sanitizeFolderName(parsedUrl?.name ?? "") ?? "repo"); + const folder = deduplicateFolderName( + requested, + folders.map((existing) => existing.folder), + ); + + const now = Date.now(); + const id = randomUUID(); + ctx.db + .insert(projectFolders) + .values({ + id, + projectId: input.projectId, + position: folders.length, + folder, + repoPath: resolved?.repoPath ?? null, + repoUrl: resolved?.parsed?.url ?? parsedUrl?.url ?? null, + baseBranch: input.baseBranch ?? null, + createdAt: now, + updatedAt: now, + }) + .run(); + + const added = findFolder(listProjectFolders(ctx.db, input.projectId), id); + return { folder: toResponse([added])[0] as FolderResponse }; + }), + + remove: protectedProcedure + .input( + z.object({ projectId: z.string().uuid(), folderId: z.string().min(1) }), + ) + .mutation(({ ctx, input }): { folders: FolderResponse[] } => { + const folders = requireMaterializedFolders(ctx, input.projectId); + const target = findFolder(folders, input.folderId); + if (target.position === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + folders.length === 1 + ? "A project always has one primary folder" + : `"${target.folder}" is the primary folder — make another folder primary first`, + }); + } + ctx.db + .delete(projectFolders) + .where( + and( + eq(projectFolders.id, target.id), + eq(projectFolders.projectId, input.projectId), + ), + ) + .run(); + reassignFolderPositions( + ctx.db, + input.projectId, + folders + .filter((folder) => folder.id !== target.id) + .map(requireFolderId), + ); + return { + folders: toResponse(listProjectFolders(ctx.db, input.projectId)), + }; + }), + + setPrimary: protectedProcedure + .input( + z.object({ projectId: z.string().uuid(), folderId: z.string().min(1) }), + ) + .mutation( + async ({ ctx, input }): Promise<{ folders: FolderResponse[] }> => { + const folders = requireMaterializedFolders(ctx, input.projectId); + const target = findFolder(folders, input.folderId); + if (target.position !== 0) { + await mirrorPrimaryOntoProject(ctx, input.projectId, target); + reassignFolderPositions(ctx.db, input.projectId, [ + target.id, + ...folders + .filter((folder) => folder.id !== target.id) + .map(requireFolderId), + ]); + } + return { + folders: toResponse(listProjectFolders(ctx.db, input.projectId)), + }; + }, + ), + + rename: protectedProcedure + .input( + z.object({ + projectId: z.string().uuid(), + folderId: z.string().min(1), + folder: z.string().min(1), + }), + ) + .mutation(({ ctx, input }): { folder: FolderResponse } => { + const folders = requireMaterializedFolders(ctx, input.projectId); + const target = findFolder(folders, input.folderId); + const folder = requireFolderName(input.folder); + const taken = folders.some( + (candidate) => + candidate.id !== target.id && + candidate.folder.toLowerCase() === folder.toLowerCase(), + ); + if (taken) { + throw new TRPCError({ + code: "CONFLICT", + message: `Another folder in this project is already called "${folder}"`, + }); + } + ctx.db + .update(projectFolders) + .set({ folder, updatedAt: Date.now() }) + .where( + and( + eq(projectFolders.id, target.id), + eq(projectFolders.projectId, input.projectId), + ), + ) + .run(); + const renamed = findFolder( + listProjectFolders(ctx.db, input.projectId), + target.id, + ); + return { folder: toResponse([renamed])[0] as FolderResponse }; + }), +}); diff --git a/packages/host-service/src/trpc/router/project/folders/index.ts b/packages/host-service/src/trpc/router/project/folders/index.ts new file mode 100644 index 00000000000..23498155194 --- /dev/null +++ b/packages/host-service/src/trpc/router/project/folders/index.ts @@ -0,0 +1 @@ +export { projectFoldersRouter } from "./folders"; diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 78358243629..9cc0d93dc02 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -33,6 +33,7 @@ import { serializeSparseCheckoutPaths, } from "../workspace-creation/shared/sparse-checkout"; import { normalizeWorktreeBaseDir } from "../workspace-creation/shared/worktree-paths"; +import { projectFoldersRouter } from "./folders"; import { createFromClone, createFromEmpty, @@ -76,6 +77,7 @@ export const projectRouter = router({ listGitHubRepositories: machineOnlyProcedure.query(() => listGitHubRepositories(), ), + folders: projectFoldersRouter, list: protectedProcedure.query(({ ctx }) => { const tagSettingsByProject = new Map(); diff --git a/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts b/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts index 16f97fd5276..043c59bf30b 100644 --- a/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts +++ b/packages/host-service/src/trpc/router/pull-requests/procedures/create-for-workspace.ts @@ -5,6 +5,7 @@ import { workspaces } from "../../../../db/schema"; import { createGitEnvResolver } from "../../../../runtime/git"; import { getHostWorkerPool } from "../../../../workers/host-worker-pool"; import { gitPrHeadBaseTask } from "../../../../workers/tasks/git"; +import { findWorkspaceRepo } from "../../../../workspaces/workspace-repos"; import { protectedProcedure } from "../../../index"; import { resolveWorktreePath } from "../../git/utils/resolve-worktree"; import { actionRejectionError } from "../../github/github"; @@ -12,6 +13,7 @@ import { resolveGithubRepo } from "../../workspace-creation/shared/project-helpe const createInputSchema = z.object({ workspaceId: z.string(), + repo: z.string().optional(), title: z.string().trim().min(1), body: z.string().optional(), draft: z.boolean().default(false), @@ -37,7 +39,17 @@ export const createForWorkspace = protectedProcedure "Workspace has no linked project, so there is no repository to open a pull request on", }); } - const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + // A secondary checkout belongs to its own project with its own remote, + // so the head branch and the GitHub coordinates must both come from the + // repo the caller selected — reading either from the workspace's own + // project would file the PR against the primary instead. + const target = findWorkspaceRepo(ctx.db, input.workspaceId, input.repo); + const prProjectId = target?.projectId ?? workspace.projectId; + const worktreePath = resolveWorktreePath( + ctx, + input.workspaceId, + input.repo, + ); const gitEnv = await createGitEnvResolver(ctx.credentials)(worktreePath); const refs = await getHostWorkerPool().run( gitPrHeadBaseTask, @@ -68,7 +80,7 @@ export const createForWorkspace = protectedProcedure }); } - const repo = await resolveGithubRepo(ctx, workspace.projectId); + const repo = await resolveGithubRepo(ctx, prProjectId); const octokit = await ctx.github(); let created: { number: number; html_url: string }; try { diff --git a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts index 320ece23751..66af6446144 100644 --- a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts +++ b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts @@ -2,9 +2,9 @@ import { existsSync, lstatSync, statSync } from "node:fs"; import { rm } from "node:fs/promises"; import { sanitizePromptForPty } from "@superset/shared/agent-prompt-launch"; import { TRPCError } from "@trpc/server"; -import { eq, isNull } from "drizzle-orm"; +import { eq, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; -import { pullRequests, workspaces } from "../../../db/schema"; +import { projects, pullRequests, workspaces } from "../../../db/schema"; import { invalidateLabelCache } from "../../../ports/static-ports"; import { coercePullRequestState } from "../../../runtime/pull-requests/utils/pull-request-mappers"; import { @@ -17,9 +17,11 @@ import type { HostServiceContext } from "../../../types"; import type { GitTaskEnv } from "../../../workers/tasks/git"; import { archiveLocalWorkspace, + type HostWorkspaceRow, trackWorkspaceDeleted, unarchiveLocalWorkspace, } from "../../../workspaces/local-workspace-store"; +import { listWorkspaceRepos } from "../../../workspaces/workspace-repos"; import type { DeleteInProgressCause, TeardownFailureCause, @@ -222,6 +224,51 @@ export const workspaceCleanupRouter = router({ ), }); +interface WorkspaceCheckout { + folder: string; + worktreePath: string; + branch: string; + /** The repository the worktree was added from, null when unknown. */ + repoPath: string | null; +} + +function listWorkspaceCheckouts( + ctx: HostServiceContext, + local: HostWorkspaceRow, + fallbackRepoPath: string | null, +): WorkspaceCheckout[] { + const repos = listWorkspaceRepos(ctx.db, local.id); + if (repos.length === 0) { + return [ + { + folder: "", + worktreePath: local.worktreePath, + branch: local.branch, + repoPath: fallbackRepoPath, + }, + ]; + } + const repoPathByProject = new Map( + ctx.db + .select({ id: projects.id, repoPath: projects.repoPath }) + .from(projects) + .where( + inArray( + projects.id, + repos.map((repo) => repo.projectId), + ), + ) + .all() + .map((row) => [row.id, row.repoPath]), + ); + return repos.map((repo) => ({ + folder: repo.folder, + worktreePath: repo.worktreePath, + branch: repo.branch, + repoPath: repoPathByProject.get(repo.projectId) ?? fallbackRepoPath, + })); +} + export async function destroyWorkspace( ctx: HostServiceContext, input: DestroyWorkspaceInput, @@ -282,37 +329,47 @@ async function runDestroy( local && (project || local.type === "session") ) { - try { - const gitEnv = await cleanupGitOps.resolveGitEnv( - ctx, - local.worktreePath, - ); - const state = await cleanupGitOps.readWorktreeState({ - worktreePath: local.worktreePath, - gitEnv, - }); - if (state.hasChanges) { - throw new TRPCError({ - code: "CONFLICT", - message: "Worktree has uncommitted changes", - }); - } - } catch (err) { - if (err instanceof TRPCError) throw err; - if (isIndeterminateGitTaskFailure(err)) { - // Timeout/pool failure: dirty-state is UNKNOWN. Fail closed on - // this destructive path rather than silently skipping the - // dirty-worktree block — a retry usually succeeds (the first - // attempt warmed the FS cache), and force skips preflight - // entirely as the explicit escape hatch. - const message = err instanceof Error ? err.message : String(err); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Couldn't verify worktree state at ${local.worktreePath}: ${message}`, + const checkouts = listWorkspaceCheckouts( + ctx, + local, + project?.repoPath ?? null, + ); + for (const checkout of checkouts) { + try { + const gitEnv = await cleanupGitOps.resolveGitEnv( + ctx, + checkout.worktreePath, + ); + const state = await cleanupGitOps.readWorktreeState({ + worktreePath: checkout.worktreePath, + gitEnv, }); + if (state.hasChanges) { + throw new TRPCError({ + code: "CONFLICT", + message: + checkouts.length > 1 + ? `Worktree has uncommitted changes in "${checkout.folder}"` + : "Worktree has uncommitted changes", + }); + } + } catch (err) { + if (err instanceof TRPCError) throw err; + if (isIndeterminateGitTaskFailure(err)) { + // Timeout/pool failure: dirty-state is UNKNOWN. Fail closed on + // this destructive path rather than silently skipping the + // dirty-worktree block — a retry usually succeeds (the first + // attempt warmed the FS cache), and force skips preflight + // entirely as the explicit escape hatch. + const message = err instanceof Error ? err.message : String(err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Couldn't verify worktree state at ${checkout.worktreePath}: ${message}`, + }); + } + // Can't read status (missing worktree dir, etc.) — not a + // conflict. Continue; step 3b will skip idempotently. } - // Can't read status (missing worktree dir, etc.) — not a - // conflict. Continue; step 3b will skip idempotently. } } @@ -419,6 +476,149 @@ function archiveReasonFor( } } +interface BranchDeletion { + repoPath: string; + branch: string; + gitEnv: GitTaskEnv; +} + +/** + * Remove one checkout from the repository that owns it, and report whether + * the directory is really gone. + */ +async function removeCheckout(args: { + ctx: HostServiceContext; + checkout: WorkspaceCheckout; + isManaged: (path: string) => boolean; + warnings: string[]; + branchDeletions: BranchDeletion[]; +}): Promise { + const { ctx, checkout, isManaged, warnings, branchDeletions } = args; + const label = checkout.folder ? ` (${checkout.folder})` : ""; + + let removed = !existsSync(checkout.worktreePath); + + if (!checkout.repoPath) { + if (!removed) { + warnings.push( + `Skipped worktree removal at ${checkout.worktreePath}${label}: project metadata is missing`, + ); + } + return removed; + } + + let gitEnv: GitTaskEnv | null = null; + if (!removed && isMissingDirectory(checkout.repoPath)) { + // The repo was moved or deleted outside Superset: there is no + // repository to run `git worktree remove` in, and the worktree's gitdir + // pointer is already dead, so no retry can ever succeed. Only a genuine + // ENOENT takes this branch — a repo this process merely cannot read + // (EPERM/EACCES) is not gone, and keeps the "failed to open" throw below + // rather than losing its worktree. + if (!isManaged(checkout.worktreePath)) { + warnings.push( + `Skipped worktree removal at ${checkout.worktreePath}${label}: project repo at ${checkout.repoPath} is missing and the folder is outside the managed worktrees root`, + ); + return false; + } + try { + await rm(checkout.worktreePath, { recursive: true, force: true }); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to remove worktree at ${checkout.worktreePath}${label}: ${message}`, + }); + } + } + + try { + gitEnv = await cleanupGitOps.resolveGitEnv(ctx, checkout.repoPath); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!removed) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to open project repo at ${checkout.repoPath}: ${message}`, + }); + } + warnings.push( + `Failed to open project repo at ${checkout.repoPath}: ${message}`, + ); + return removed; + } + + branchDeletions.push({ + repoPath: checkout.repoPath, + branch: checkout.branch, + gitEnv, + }); + + // A task failure here means the post-remove state is unknown — treat that + // like "still registered" and block rather than risk orphaning disk past + // the archive commit point. + let stillRegistered = true; + let removeError: string | undefined; + try { + ({ stillRegistered, removeError } = await cleanupGitOps.removeWorktree({ + repoPath: checkout.repoPath, + worktreePath: checkout.worktreePath, + gitEnv, + })); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to verify worktree removal at ${checkout.worktreePath}${label}: ${message}`, + }); + } + if (stillRegistered) { + // git still tracks a live worktree here — removal genuinely failed. + // Un-archive so the workspace stays visible and retryable instead of + // orphaning disk past the commit point. + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to remove worktree at ${checkout.worktreePath}${label}${ + removeError ? `: ${removeError}` : "" + }`, + }); + } + + if (!isMissingPath(checkout.worktreePath)) { + // Unregistered is not removed: git's unregistration and its recursive + // delete are not atomic, so `remove --force --force` can drop the + // registration and still fail partway through deleting files (locked + // file, live writer). Trusting the registry alone silently orphaned the + // folder — no list shows it, and a retry reports success without + // touching it (#6730). + if (!isManaged(checkout.worktreePath)) { + warnings.push( + `Worktree at ${checkout.worktreePath}${label} is no longer registered with git, but its folder is outside the managed worktrees root and was left on disk`, + ); + } else { + try { + await removeDirectoryTree(checkout.worktreePath); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Worktree at ${checkout.worktreePath}${label} is no longer registered with git, but its folder could not be removed: ${message}${ + removeError ? ` (git worktree remove: ${removeError})` : "" + }`, + }); + } + } + } + // The outside-root branch above leaves the folder in place, so report + // removal from the final disk state rather than assuming this path always + // cleared it (#6785 review). `isMissingPath` rather than `existsSync`: a + // leftover this process cannot read, or a dangling symlink, still exists + // and must not be reported as removed. + removed = isMissingPath(checkout.worktreePath); + return removed; +} + async function runDestroyPhases( ctx: HostServiceContext, input: DestroyWorkspaceInput, @@ -453,7 +653,6 @@ async function runDestroyPhases( // whole worktree directory, which would otherwise stall the loop. let worktreeRemoved = false; let branchDeleted = false; - let repoGitEnv: GitTaskEnv | null = null; if (sharesProjectCheckout) { // The files are the repository itself; nothing on disk belongs to // this workspace alone. @@ -490,132 +689,46 @@ async function runDestroyPhases( ); } } + const branchDeletions: BranchDeletion[] = []; if (local && project && !sharesProjectCheckout) { - worktreeRemoved = !existsSync(local.worktreePath); - if (!worktreeRemoved && isMissingDirectory(project.repoPath)) { - // The project repo was moved or deleted outside Superset: there is - // no repository to run `git worktree remove` in, and the worktree's - // gitdir pointer is already dead, so no retry can ever succeed. - // Only a genuine ENOENT takes this branch — a repo this process - // merely cannot read (EPERM/EACCES) is not gone, and keeps the - // "failed to open" throw below rather than losing its worktree. - // Delete the folder directly under the same root guard the - // sessions branch uses — anything outside the project's managed - // worktrees root is left on disk (warned) while the delete proceeds. - const worktreeBaseDir = - project.worktreeBaseDir ?? getHostWorktreeBaseDir(ctx); - if ( - !isInsideProjectWorktreesRoot( - local.worktreePath, - project, - worktreeBaseDir, - ) - ) { + const checkouts = listWorkspaceCheckouts(ctx, local, project.repoPath); + // Always the PRIMARY project's root: a secondary folder's worktree lives + // inside the container under that root, not under its own project's, so + // guarding it with its own project id would refuse to clean it up. + const worktreeBaseDir = + project.worktreeBaseDir ?? getHostWorktreeBaseDir(ctx); + const isManaged = (path: string) => + isInsideProjectWorktreesRoot(path, project, worktreeBaseDir); + + let allRemoved = true; + for (const checkout of checkouts) { + const removed = await removeCheckout({ + ctx, + checkout, + isManaged, + warnings, + branchDeletions, + }); + if (!removed) allRemoved = false; + } + + if (allRemoved && local.rootPath && !isMissingPath(local.rootPath)) { + if (!isManaged(local.rootPath)) { warnings.push( - `Skipped worktree removal at ${local.worktreePath}: project repo at ${project.repoPath} is missing and the folder is outside the managed worktrees root`, + `Skipped workspace folder removal at ${local.rootPath}: it is outside the managed worktrees root`, ); } else { try { - await rm(local.worktreePath, { recursive: true, force: true }); - worktreeRemoved = true; + await removeDirectoryTree(local.rootPath); } catch (err) { const message = err instanceof Error ? err.message : String(err); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to remove worktree at ${local.worktreePath}: ${message}`, - }); - } - } - } else { - try { - repoGitEnv = await cleanupGitOps.resolveGitEnv(ctx, project.repoPath); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (!worktreeRemoved) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to open project repo at ${project.repoPath}: ${message}`, - }); - } - warnings.push( - `Failed to open project repo at ${project.repoPath}: ${message}`, - ); - } - } - - if (repoGitEnv) { - // A task failure here means the post-remove state is unknown — - // treat that like "still registered" and block rather than risk - // orphaning disk past the archive commit point. - let stillRegistered = true; - let removeError: string | undefined; - try { - ({ stillRegistered, removeError } = await cleanupGitOps.removeWorktree({ - repoPath: project.repoPath, - worktreePath: local.worktreePath, - gitEnv: repoGitEnv, - })); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to verify worktree removal at ${local.worktreePath}: ${message}`, - }); - } - if (stillRegistered) { - // git still tracks a live worktree here — removal genuinely - // failed. Un-archive so the workspace stays visible and - // retryable instead of orphaning disk past the commit point. - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to remove worktree at ${local.worktreePath}${ - removeError ? `: ${removeError}` : "" - }`, - }); - } - if (!isMissingPath(local.worktreePath)) { - // Unregistered is not removed: git's unregistration and its - // recursive delete are not atomic, so `remove --force --force` - // can drop the registration and still fail partway through - // deleting files (locked file, live writer). Trusting the - // registry alone silently orphaned the folder — no list shows - // it, and a retry reports success without touching it (#6730). - // Fall back to the same guarded direct removal the other - // branches use. - const worktreeBaseDir = - project.worktreeBaseDir ?? getHostWorktreeBaseDir(ctx); - if ( - !isInsideProjectWorktreesRoot( - local.worktreePath, - project, - worktreeBaseDir, - ) - ) { warnings.push( - `Worktree at ${local.worktreePath} is no longer registered with git, but its folder is outside the managed worktrees root and was left on disk`, + `Failed to remove workspace folder at ${local.rootPath}: ${message}`, ); - } else { - try { - await removeDirectoryTree(local.worktreePath); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Worktree at ${local.worktreePath} is no longer registered with git, but its folder could not be removed: ${message}${ - removeError ? ` (git worktree remove: ${removeError})` : "" - }`, - }); - } } } - // The outside-root branch above leaves the folder in place, so - // report removal from the final disk state rather than assuming - // this path always cleared it (#6785 review). `isMissingPath` - // rather than `existsSync`: a leftover this process cannot read, - // or a dangling symlink, still exists and must not be reported - // as removed. - worktreeRemoved = isMissingPath(local.worktreePath); } + worktreeRemoved = allRemoved; } // ─── Step 4: Optional branch delete ──────────────────────────── @@ -624,17 +737,20 @@ async function runDestroyPhases( // satisfies the goal, so the task skips the delete without a scary // warning; a thrown git failure lands in the warning below rather than // being mistaken for "already gone". - if (repoGitEnv && project && local?.branch && input.deleteBranch) { - try { - await cleanupGitOps.deleteLocalBranch({ - repoPath: project.repoPath, - branch: local.branch, - gitEnv: repoGitEnv, - }); - branchDeleted = true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - warnings.push(`Failed to delete branch ${local.branch}: ${message}`); + if (input.deleteBranch) { + for (const deletion of branchDeletions) { + if (!deletion.branch) continue; + try { + await cleanupGitOps.deleteLocalBranch({ + repoPath: deletion.repoPath, + branch: deletion.branch, + gitEnv: deletion.gitEnv, + }); + branchDeleted = true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + warnings.push(`Failed to delete branch ${deletion.branch}: ${message}`); + } } } diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/base-ref-fetcher.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/base-ref-fetcher.ts new file mode 100644 index 00000000000..f612cd9c6c3 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/base-ref-fetcher.ts @@ -0,0 +1,26 @@ +import { createGitEnvResolver } from "../../../../runtime/git"; +import type { HostServiceContext } from "../../../../types"; +import { getHostWorkerPool } from "../../../../workers/host-worker-pool"; +import { gitFetchBaseRefTask } from "../../../../workers/tasks/git"; +import type { BaseRefFetcher } from "../utils/resolve-new-branch-start-point"; + +/** Base-ref fetch for workspace creation, executed in the worker pool so the + * network fetch's spawn + stdout drain stay off the host-service event loop. + * Concurrent creates on the same base coalesce into one fetch. */ +export function createWorkerBaseRefFetcher( + ctx: Pick, + repoPath: string, +): BaseRefFetcher { + return async (target) => { + const gitEnv = await createGitEnvResolver(ctx.credentials)(repoPath); + return getHostWorkerPool().run( + gitFetchBaseRefTask, + { worktreePath: repoPath, target, gitEnv }, + { + timeoutMs: 30_000, + strategy: "coalesce", + dedupeKey: `${repoPath}:base-ref:${target.remote}/${target.branch}`, + }, + ); + }; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/branch-worktree.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/branch-worktree.ts new file mode 100644 index 00000000000..3735c1e399f --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/branch-worktree.ts @@ -0,0 +1,107 @@ +import type { ResolvedRef } from "../../../../runtime/git/refs"; +import { findWorktreeAtPath } from "./branch-search"; +import { addWorktreeWithSparseCheckout } from "./sparse-checkout"; +import type { GitClient } from "./types"; + +export interface BranchSourcePlan { + branch: string; + startPoint: ResolvedRef; + usedExistingBranch: boolean; +} + +// Adopt any worktree git knows about, no matter where it lives — +// tools other than Superset can also `git worktree add`, and their +// worktrees are valid adoption targets. +export function isBranchInUseByWorktreeError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err ?? ""); + const lower = message.toLowerCase(); + return ( + lower.includes("is already used by worktree") || + lower.includes("already checked out") + ); +} + +export async function addBranchWorktree(args: { + git: GitClient; + plan: BranchSourcePlan; + worktreePath: string; + sparsePaths: string[]; +}): Promise { + const { git, plan, worktreePath, sparsePaths } = args; + + // Post-checkout hooks run after the checkout itself, so a hook that exits + // non-zero fails the operation with the worktree fully in place. Every + // branch case below checks out `plan.branch`, so registered-at-path with + // that branch is the ground truth. Handed to addWorktreeWithSparseCheckout + // so it applies to whichever command actually performs the checkout — + // the plain add below, or the sparse path's explicit `checkout` step. + const hookTolerance = { + context: `Worktree created at ${worktreePath}`, + didSucceed: async () => { + if (!(await findWorktreeAtPath(git, worktreePath, plan.branch))) { + return false; + } + try { + // The worktree list can report a branch for a half-created + // worktree; require a resolvable HEAD in the worktree itself. + await git.raw(["-C", worktreePath, "rev-parse", "--verify", "HEAD"]); + return true; + } catch { + return false; + } + }, + }; + + if (plan.usedExistingBranch) { + // Existing branch — check it out into a fresh worktree. Remote-tracking + // refs need explicit --track + -b so the worktree gets a real local + // branch, not detached HEAD. + await addWorktreeWithSparseCheckout({ + git, + worktreeArgs: + plan.startPoint.kind === "remote-tracking" + ? [ + "--track", + "-b", + plan.branch, + worktreePath, + plan.startPoint.remoteShortName, + ] + : [ + worktreePath, + plan.startPoint.kind === "head" + ? "HEAD" + : plan.startPoint.shortName, + ], + worktreePath, + sparsePaths, + logPrefix: "[workspaces.create]", + hookTolerance, + }); + return; + } + + // New branch from start point. --no-track keeps `git pull` and + // ahead/behind counts pointing at the branch's own upstream once + // push.autoSetupRemote sets it on first push. + const startPointArg = + plan.startPoint.kind === "head" + ? "HEAD" + : plan.startPoint.kind === "remote-tracking" + ? plan.startPoint.remoteShortName + : plan.startPoint.shortName; + await addWorktreeWithSparseCheckout({ + git, + worktreeArgs: [ + "--no-track", + "-b", + plan.branch, + worktreePath, + startPointArg, + ], + worktreePath, + sparsePaths, + logPrefix: "[workspaces.create]", + hookTolerance, + }); +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/create-worktrees.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/create-worktrees.test.ts new file mode 100644 index 00000000000..7daefc98c34 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/create-worktrees.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createUserSimpleGit } from "../../../../../runtime/git/simple-git"; +import type { HostServiceContext } from "../../../../../types"; +import { createMultiRepoWorktrees } from "./create-worktrees"; +import type { ResolvedFolder } from "./resolve-folders"; + +const TEST_DIR = join( + realpathSync(tmpdir()), + `superset-hs-multi-repo-${process.pid}`, +); + +function createTestRepo(name: string): string { + const repoPath = join(TEST_DIR, name); + mkdirSync(repoPath, { recursive: true }); + execSync("git init -b main", { cwd: repoPath, stdio: "ignore" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoPath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { cwd: repoPath, stdio: "ignore" }); + execSync("git commit --allow-empty -m init", { + cwd: repoPath, + stdio: "ignore", + }); + return repoPath; +} + +/** Records every argv git is handed, so a test can assert what never reaches it. */ +const gitArgs: string[][] = []; + +function recordingCtx(): HostServiceContext { + return { + credentials: { + getCredentials: async () => ({ env: {} }), + getToken: async () => null, + credentialRemedy: () => "", + }, + git: async (repoPath: string) => { + const client = createUserSimpleGit(repoPath); + return new Proxy(client, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + gitArgs.push([ + String(property), + ...args.flatMap((arg) => + Array.isArray(arg) ? arg.map(String) : [String(arg)], + ), + ]); + return (value as (...a: unknown[]) => unknown).apply(target, args); + }; + }, + }); + }, + } as unknown as HostServiceContext; +} + +function folder( + position: number, + name: string, + repoPath: string, +): ResolvedFolder { + return { + position, + folder: name, + projectId: `project-${name}`, + repoPath, + baseBranch: null, + sparsePaths: [], + }; +} + +async function planFor( + repoPath: string, + branch: string, +): Promise[0]["primaryPlan"]> { + const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath }) + .toString() + .trim(); + return { + branch, + startPoint: { kind: "local", shortName: head }, + usedExistingBranch: false, + } as Parameters[0]["primaryPlan"]; +} + +describe("createMultiRepoWorktrees", () => { + beforeEach(() => { + gitArgs.length = 0; + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + test("keeps the same branch name in every repo where it is free", async () => { + const api = createTestRepo("free-api"); + const web = createTestRepo("free-web"); + const container = join(TEST_DIR, "free-container"); + + const repos = await createMultiRepoWorktrees({ + ctx: recordingCtx(), + containerPath: container, + folders: [folder(0, "api", api), folder(1, "web", web)], + primaryPlan: await planFor(api, "feature/shared"), + baseBranch: undefined, + onProgress: () => {}, + }); + + expect(repos.map((repo) => repo.branch)).toEqual([ + "feature/shared", + "feature/shared", + ]); + expect(repos.map((repo) => repo.worktreePath)).toEqual([ + join(container, "api"), + join(container, "web"), + ]); + }); + + test("bumps only the repo where the branch is already taken", async () => { + const api = createTestRepo("taken-api"); + const web = createTestRepo("taken-web"); + execSync("git branch feature/shared", { cwd: web, stdio: "ignore" }); + const container = join(TEST_DIR, "taken-container"); + + const repos = await createMultiRepoWorktrees({ + ctx: recordingCtx(), + containerPath: container, + folders: [folder(0, "api", api), folder(1, "web", web)], + primaryPlan: await planFor(api, "feature/shared"), + baseBranch: undefined, + onProgress: () => {}, + }); + + expect(repos.map((repo) => repo.branch)).toEqual([ + "feature/shared", + "feature/shared-2", + ]); + }); + + test("the container path is never handed to git", async () => { + const api = createTestRepo("invariant-api"); + const web = createTestRepo("invariant-web"); + const container = join(TEST_DIR, "invariant-container"); + + await createMultiRepoWorktrees({ + ctx: recordingCtx(), + containerPath: container, + folders: [folder(0, "api", api), folder(1, "web", web)], + primaryPlan: await planFor(api, "feature/invariant"), + baseBranch: undefined, + onProgress: () => {}, + }); + + const everyArg = gitArgs.flat(); + expect(everyArg).not.toContain(container); + expect(everyArg).toContain(join(container, "api")); + + for (const repoPath of [api, web]) { + const list = execSync("git worktree list --porcelain", { + cwd: repoPath, + }).toString(); + expect(list).not.toContain(`worktree ${container}\n`); + } + }); + + test("a failing folder rolls back every worktree, branch and the container", async () => { + const api = createTestRepo("rollback-api"); + const broken = join(TEST_DIR, "rollback-broken"); + mkdirSync(broken, { recursive: true }); + const container = join(TEST_DIR, "rollback-container"); + + await expect( + createMultiRepoWorktrees({ + ctx: recordingCtx(), + containerPath: container, + folders: [folder(0, "api", api), folder(1, "broken", broken)], + primaryPlan: await planFor(api, "feature/rollback"), + baseBranch: undefined, + onProgress: () => {}, + }), + ).rejects.toThrow(/folder "broken"/); + + expect(existsSync(container)).toBe(false); + const list = execSync("git worktree list --porcelain", { + cwd: api, + }).toString(); + expect(list).not.toContain(container); + const branches = execSync("git branch --list", { cwd: api }).toString(); + expect(branches).not.toContain("feature/rollback"); + }); +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/create-worktrees.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/create-worktrees.ts new file mode 100644 index 00000000000..c52a521b339 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/create-worktrees.ts @@ -0,0 +1,193 @@ +import { existsSync, mkdirSync, readdirSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { TRPCError } from "@trpc/server"; +import type { HostServiceContext } from "../../../../../types"; +import { listBranchNames } from "../../utils/list-branch-names"; +import { resolveNewBranchStartPoint } from "../../utils/resolve-new-branch-start-point"; +import { deduplicateBranchName } from "../../utils/sanitize-branch"; +import { createWorkerBaseRefFetcher } from "../base-ref-fetcher"; +import { addBranchWorktree, type BranchSourcePlan } from "../branch-worktree"; +import { enablePushAutoSetupRemote } from "../git-config"; +import type { GitClient } from "../types"; +import type { ProgressReporter, ResolvedFolder } from "./resolve-folders"; + +const LOG_PREFIX = "[workspaces.create]"; + +export interface CreatedWorkspaceRepo { + position: number; + folder: string; + projectId: string; + worktreePath: string; + branch: string; + baseBranch: string | null; + /** Rollback territory: the repository the worktree was added from. Unlike + * the fields above, not part of the persisted row. */ + repoPath: string; + createdBranch: boolean; +} + +export interface CreateMultiRepoWorktreesArgs { + ctx: HostServiceContext; + containerPath: string; + folders: ResolvedFolder[]; + primaryPlan: BranchSourcePlan; + baseBranch: string | undefined; + onProgress: ProgressReporter; +} + +/** + * Check every folder out into `/`, primary first. + * + * All-or-nothing: the first failure rolls the earlier folders back before + * throwing, so a partial multi-repo workspace can never reach the database. + */ +export async function createMultiRepoWorktrees( + args: CreateMultiRepoWorktreesArgs, +): Promise { + const { ctx, containerPath, folders, primaryPlan, baseBranch } = args; + + if (existsSync(containerPath) && readdirSync(containerPath).length > 0) { + throw new TRPCError({ + code: "CONFLICT", + message: `Workspace folder already exists and is not empty: ${containerPath}`, + }); + } + mkdirSync(containerPath, { recursive: true }); + + const created: CreatedWorkspaceRepo[] = []; + let failedFolder = folders[0]?.folder ?? ""; + + try { + for (const folder of folders) { + failedFolder = folder.folder; + const git = await ctx.git(folder.repoPath); + // Frees branches still claimed by registrations whose directories + // are gone. + await git + .raw(["worktree", "prune"]) + .catch((err) => + console.warn(`${LOG_PREFIX} worktree prune failed:`, err), + ); + + const plan = + folder.position === 0 + ? primaryPlan + : await planSecondaryBranch({ + ctx, + git, + folder, + branch: primaryPlan.branch, + baseBranch, + }); + + const worktreePath = join(containerPath, folder.folder); + args.onProgress(`Checking out ${plan.branch} into ${folder.folder}/ …`); + await addBranchWorktree({ + git, + plan, + worktreePath, + sparsePaths: folder.sparsePaths, + }); + + const recordedBase = + !plan.usedExistingBranch && plan.startPoint.kind !== "head" + ? plan.startPoint.shortName + : null; + created.push({ + position: folder.position, + folder: folder.folder, + projectId: folder.projectId, + worktreePath, + branch: plan.branch, + baseBranch: recordedBase, + repoPath: folder.repoPath, + createdBranch: !plan.usedExistingBranch, + }); + + await enablePushAutoSetupRemote(git, worktreePath, LOG_PREFIX); + if (recordedBase) { + await git + .raw([ + "-C", + worktreePath, + "config", + `branch.${plan.branch}.base`, + recordedBase, + ]) + .catch((err) => { + console.warn( + `${LOG_PREFIX} failed to record base branch ${recordedBase}:`, + err, + ); + }); + } + } + } catch (err) { + await rollbackMultiRepoWorktrees(ctx, created, containerPath); + const message = err instanceof Error ? err.message : String(err); + throw new TRPCError({ + code: "CONFLICT", + message: `Failed to set up folder "${failedFolder}": ${message}`, + }); + } + + return created; +} + +/** + * The same branch name in every repository where it is free, bumped only + * where it is taken — one repo's collision must not rename it everywhere. + */ +async function planSecondaryBranch(args: { + ctx: HostServiceContext; + git: GitClient; + folder: ResolvedFolder; + branch: string; + baseBranch: string | undefined; +}): Promise { + const existing = await listBranchNames(args.ctx, args.folder.repoPath); + const taken = existing.some( + (name) => name.toLowerCase() === args.branch.toLowerCase(), + ); + const branch = taken + ? deduplicateBranchName(args.branch, existing) + : args.branch; + const startPoint = await resolveNewBranchStartPoint( + args.git, + args.folder.baseBranch ?? args.baseBranch, + createWorkerBaseRefFetcher(args.ctx, args.folder.repoPath), + ); + return { branch, startPoint, usedExistingBranch: false }; +} + +/** Also the caller's rollback when persisting the workspace row fails. */ +export async function rollbackMultiRepoWorktrees( + ctx: HostServiceContext, + created: CreatedWorkspaceRepo[], + containerPath: string, +): Promise { + for (const repo of [...created].reverse()) { + try { + const git = await ctx.git(repo.repoPath); + await git.raw(["worktree", "remove", "--force", repo.worktreePath]); + if (repo.createdBranch) { + await git.raw(["branch", "-D", repo.branch]); + } + } catch (err) { + console.warn(`${LOG_PREFIX} multi-repo rollback failed`, { + folder: repo.folder, + worktreePath: repo.worktreePath, + err, + }); + } + } + // The container is ours — this call created it over an empty or absent + // path — so removing it cannot take anything the user put there. + await rm(containerPath, { recursive: true, force: true }).catch((err) => { + console.warn(`${LOG_PREFIX} failed to remove container`, { + containerPath, + err, + }); + }); +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/index.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/index.ts new file mode 100644 index 00000000000..00bbb383ff7 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/index.ts @@ -0,0 +1,12 @@ +export { + type CreatedWorkspaceRepo, + createMultiRepoWorktrees, + rollbackMultiRepoWorktrees, +} from "./create-worktrees"; +export { + effectiveProjectFolders, + managedReposRoot, + type ProgressReporter, + type ResolvedFolder, + resolveFolderProjects, +} from "./resolve-folders"; diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/resolve-folders.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/resolve-folders.ts new file mode 100644 index 00000000000..bc98c2d3c73 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/resolve-folders.ts @@ -0,0 +1,244 @@ +import { randomUUID } from "node:crypto"; +import { statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseGitHubRemote } from "@superset/shared/github-remote"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { projectFolders, projects } from "../../../../../db/schema"; +import { + deduplicateFolderName, + defaultFolderNameForRepo, + listProjectFolders, + type ProjectFolder, +} from "../../../../../projects/project-folders"; +import type { HostServiceContext } from "../../../../../types"; +import { persistLocalProject } from "../../../project/utils/persist-project"; +import { + cloneRepoInto, + resolveLocalRepo, +} from "../../../project/utils/resolve-repo"; +import { parseSparseCheckoutPaths } from "../sparse-checkout"; + +export function managedReposRoot(): string { + return join(homedir(), ".superset", "repos"); +} + +export interface ResolvedFolder { + position: number; + folder: string; + projectId: string; + repoPath: string; + baseBranch: string | null; + sparsePaths: string[]; +} + +export type ProgressReporter = (line: string) => void; + +/** + * The project's own folders plus one per extra project id. Extra ids never + * mutate the project's folder list — they shape this workspace only. + */ +export function effectiveProjectFolders( + ctx: HostServiceContext, + projectId: string, + extraProjectIds: string[] | undefined, +): Array { + const base = listProjectFolders(ctx.db, projectId).map((folder) => ({ + ...folder, + linkedProjectId: folder.position === 0 ? projectId : null, + })); + if (!extraProjectIds?.length) return base; + + const taken = base.map((folder) => folder.folder); + const extras = extraProjectIds.map((extraId, index) => { + const project = ctx.db + .select() + .from(projects) + .where(eq(projects.id, extraId)) + .get(); + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project is not set up on this host: ${extraId}`, + }); + } + const folder = deduplicateFolderName( + defaultFolderNameForRepo(project.repoPath), + taken, + ); + taken.push(folder); + return { + id: null, + projectId, + position: base.length + index, + folder, + repoPath: project.repoPath, + repoUrl: project.repoUrl, + baseBranch: null, + linkedProjectId: extraId, + }; + }); + return [...base, ...extras]; +} + +function isDirectory(path: string): boolean { + return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true; +} + +function findProjectByRepoPath( + ctx: HostServiceContext, + repoPath: string, +): string | null { + return ( + ctx.db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.repoPath, repoPath)) + .get()?.id ?? null + ); +} + +function findProjectByRepoUrl( + ctx: HostServiceContext, + repoUrl: string, +): { id: string; repoPath: string } | null { + const target = repoUrl.toLowerCase(); + const match = ctx.db + .select({ + id: projects.id, + repoPath: projects.repoPath, + url: projects.repoUrl, + }) + .from(projects) + .all() + .find( + (row) => row.url?.toLowerCase() === target && isDirectory(row.repoPath), + ); + return match ? { id: match.id, repoPath: match.repoPath } : null; +} + +async function adoptCheckout( + ctx: HostServiceContext, + repoPath: string, +): Promise { + const existing = findProjectByRepoPath(ctx, repoPath); + if (existing) return existing; + const resolved = await resolveLocalRepo(repoPath); + const owner = findProjectByRepoPath(ctx, resolved.repoPath); + if (owner) return owner; + const id = randomUUID(); + persistLocalProject(ctx, id, resolved); + return id; +} + +/** + * Give every folder a checkout on this host and the local project that owns + * it. + */ +export async function resolveFolderProjects(args: { + ctx: HostServiceContext; + folders: Array; + onProgress: ProgressReporter; +}): Promise { + const { ctx, folders, onProgress } = args; + const resolved: ResolvedFolder[] = []; + const projectIdByFolder = new Map(); + + for (const folder of folders) { + const projectId = await resolveOneFolder(ctx, folder, onProgress); + const clash = [...projectIdByFolder.entries()].find( + ([, id]) => id === projectId, + ); + if (clash) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Folders "${clash[0]}" and "${folder.folder}" resolve to the same repository — a workspace can only check out each repository once`, + }); + } + projectIdByFolder.set(folder.folder, projectId); + + const project = ctx.db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Project disappeared while resolving folder "${folder.folder}"`, + }); + } + resolved.push({ + position: folder.position, + folder: folder.folder, + projectId, + repoPath: project.repoPath, + baseBranch: folder.baseBranch, + sparsePaths: parseSparseCheckoutPaths(project.sparseCheckoutPaths), + }); + } + + return resolved.sort((a, b) => a.position - b.position); +} + +async function resolveOneFolder( + ctx: HostServiceContext, + folder: ProjectFolder & { linkedProjectId: string | null }, + onProgress: ProgressReporter, +): Promise { + if (folder.linkedProjectId) return folder.linkedProjectId; + + if (folder.repoPath && isDirectory(folder.repoPath)) { + return adoptCheckout(ctx, folder.repoPath); + } + + if (!folder.repoUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Folder "${folder.folder}" has no repository on this host and no clone URL to fetch one from`, + }); + } + + const byRemote = findProjectByRepoUrl(ctx, folder.repoUrl); + if (byRemote) { + recordFolderCheckout(ctx, folder, byRemote.repoPath); + return byRemote.id; + } + + const parsed = parseGitHubRemote(folder.repoUrl); + if (!parsed) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Folder "${folder.folder}" has an unrecognized clone URL: ${folder.repoUrl}`, + }); + } + const parentDir = resolve(managedReposRoot(), parsed.owner); + onProgress( + `Cloning ${parsed.owner}/${parsed.name} for folder "${folder.folder}"…`, + ); + const cloned = await cloneRepoInto( + folder.repoUrl, + parentDir, + ctx.credentials, + ); + onProgress(`Cloned ${parsed.owner}/${parsed.name} into ${cloned.repoPath}`); + const projectId = randomUUID(); + persistLocalProject(ctx, projectId, cloned); + recordFolderCheckout(ctx, folder, cloned.repoPath); + return projectId; +} + +/** Lets the next create link the existing checkout instead of re-cloning. */ +function recordFolderCheckout( + ctx: HostServiceContext, + folder: ProjectFolder, + repoPath: string, +): void { + if (!folder.id) return; + ctx.db + .update(projectFolders) + .set({ repoPath, updatedAt: Date.now() }) + .where(eq(projectFolders.id, folder.id)) + .run(); +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts index 7d54c7a3e2b..7e0faf87ea5 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts @@ -17,6 +17,11 @@ interface StartSetupTerminalArgs { * resolves — the caller must then dispatch it separately. */ chainCommand?: string; + /** + * Lines echoed before the setup command runs. Dropped when no setup + * command resolves — there is then no terminal to put them in. + */ + preamble?: string[]; } interface StartSetupTerminalResult { @@ -67,9 +72,17 @@ export async function startSetupTerminalIfPresent( return { terminal: null, warning: null, chained: false }; } - const initialCommand = args.chainCommand + const setupCommand = args.chainCommand ? `${resolved.initialCommand} && ${args.chainCommand}` : resolved.initialCommand; + // `\\n`, not `\n`: the initial command is typed into the PTY, so a real + // newline here would submit the line mid-quote instead of reaching printf. + const preamble = (args.preamble ?? []) + .map((line) => `printf '%s\\n' ${shellSingleQuote(line)}`) + .join("; "); + const initialCommand = preamble + ? `${preamble}; ${setupCommand}` + : setupCommand; const terminalId = crypto.randomUUID(); const result = await createTerminalSessionInternal({ diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index c14a5049f38..634a0482bb7 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -11,6 +11,7 @@ import { toCloudShape, updateLocalWorkspace, } from "../../../workspaces/local-workspace-store"; +import { describeWorkspaceReposByWorkspaceId } from "../../../workspaces/workspace-repos"; import { protectedProcedure, router } from "../../index"; import { resolveWorktreePath } from "../git/utils/resolve-worktree"; import { destroyWorkspace } from "../workspace-cleanup"; @@ -33,6 +34,10 @@ export const workspaceRouter = router({ return { ...localWorkspace, worktreeExists: existsSync(localWorkspace.worktreePath), + repos: + describeWorkspaceReposByWorkspaceId(ctx.db, [localWorkspace]).get( + localWorkspace.id, + ) ?? [], }; }), @@ -74,10 +79,16 @@ export const workspaceRouter = router({ rows.map((row) => row.id), ctx.userId, ); + const reposByWorkspaceId = describeWorkspaceReposByWorkspaceId( + ctx.db, + rows, + ); return rows.map((row) => ({ ...toCloudShape(row, ctx.organizationId), tags: tagsByWorkspaceId.get(row.id) ?? [], worktreePath: row.worktreePath, + rootPath: row.rootPath, + repos: reposByWorkspaceId.get(row.id) ?? [], // Tombstones' worktrees are gone by definition; stat-checking an // unbounded, forever-growing archive on every poll adds up. worktreeExists: diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index 71bf5e471d9..255a0c096f1 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -10,15 +10,11 @@ import { TRPCError } from "@trpc/server"; import { and, eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; -import { createGitEnvResolver } from "../../../runtime/git"; import { getGitAuthorName } from "../../../runtime/git/identity"; -import { type ResolvedRef, resolveRef } from "../../../runtime/git/refs"; +import { resolveRef } from "../../../runtime/git/refs"; import type { HostServiceContext } from "../../../types"; import { getHostWorkerPool } from "../../../workers/host-worker-pool"; -import { - gitAuthorNameTask, - gitFetchBaseRefTask, -} from "../../../workers/tasks/git"; +import { gitAuthorNameTask } from "../../../workers/tasks/git"; import { type CloudShapedWorkspace, getLocalWorkspace, @@ -44,11 +40,16 @@ import { import { getHostWorktreeBaseDir } from "../settings/worktree-location"; import { createSession } from "../workspace-creation/procedures/create-session"; import { adoptExistingWorktree } from "../workspace-creation/shared/adopt-existing-worktree"; +import { createWorkerBaseRefFetcher } from "../workspace-creation/shared/base-ref-fetcher"; import { - findWorktreeAtPath, getWorktreeBranchAtPath, listWorktreeBranches, } from "../workspace-creation/shared/branch-search"; +import { + addBranchWorktree, + type BranchSourcePlan, + isBranchInUseByWorktreeError, +} from "../workspace-creation/shared/branch-worktree"; import { startCommandTerminal } from "../workspace-creation/shared/command-terminal"; import { type AgentLaunchResult, @@ -61,6 +62,13 @@ import { requireLocalProject, requireProjectRepoPath, } from "../workspace-creation/shared/local-project"; +import { + type CreatedWorkspaceRepo, + createMultiRepoWorktrees, + effectiveProjectFolders, + resolveFolderProjects, + rollbackMultiRepoWorktrees, +} from "../workspace-creation/shared/multi-repo"; import { requireIndependentWorktree } from "../workspace-creation/shared/require-independent-worktree"; import { startSetupTerminalIfPresent } from "../workspace-creation/shared/setup-terminal"; import { @@ -97,6 +105,11 @@ import { deduplicateBranchName } from "../workspace-creation/utils/sanitize-bran const createInputSchema = z .object({ projectId: z.string(), + /** + * Extra folders of THIS workspace only — the project's own folder list + * is not modified. + */ + extraProjectIds: z.array(z.string()).optional(), // "worktree" (default) checks out `branch` in its own worktree. // "local" registers a new workspace on the project's primary checkout // — no clone, no worktree, no branch switch; files, the git index and @@ -325,33 +338,6 @@ async function getLocalBranchHead( } } -export interface BranchSourcePlan { - branch: string; - startPoint: ResolvedRef; - usedExistingBranch: boolean; -} - -/** Base-ref fetch for workspace creation, executed in the worker pool so the - * network fetch's spawn + stdout drain stay off the host-service event loop. - * Concurrent creates on the same base coalesce into one fetch. */ -function createWorkerBaseRefFetcher( - ctx: Pick, - repoPath: string, -): BaseRefFetcher { - return async (target) => { - const gitEnv = await createGitEnvResolver(ctx.credentials)(repoPath); - return getHostWorkerPool().run( - gitFetchBaseRefTask, - { worktreePath: repoPath, target, gitEnv }, - { - timeoutMs: 30_000, - strategy: "coalesce", - dedupeKey: `${repoPath}:base-ref:${target.remote}/${target.branch}`, - }, - ); - }; -} - /** * `resolveProjectBranchPrefix`'s `getAuthorName` for callers with no other * git need (unlike `create`, which already holds an on-loop client bound to @@ -406,103 +392,6 @@ async function planBranchSource( return { branch, startPoint, usedExistingBranch: false }; } -// Adopt any worktree git knows about, no matter where it lives — -// tools other than Superset can also `git worktree add`, and their -// worktrees are valid adoption targets. -function isBranchInUseByWorktreeError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err ?? ""); - const lower = message.toLowerCase(); - return ( - lower.includes("is already used by worktree") || - lower.includes("already checked out") - ); -} - -export async function addBranchWorktree(args: { - git: GitClient; - plan: BranchSourcePlan; - worktreePath: string; - sparsePaths: string[]; -}): Promise { - const { git, plan, worktreePath, sparsePaths } = args; - - // Post-checkout hooks run after the checkout itself, so a hook that exits - // non-zero fails the operation with the worktree fully in place. Every - // branch case below checks out `plan.branch`, so registered-at-path with - // that branch is the ground truth. Handed to addWorktreeWithSparseCheckout - // so it applies to whichever command actually performs the checkout — - // the plain add below, or the sparse path's explicit `checkout` step. - const hookTolerance = { - context: `Worktree created at ${worktreePath}`, - didSucceed: async () => { - if (!(await findWorktreeAtPath(git, worktreePath, plan.branch))) { - return false; - } - try { - // The worktree list can report a branch for a half-created - // worktree; require a resolvable HEAD in the worktree itself. - await git.raw(["-C", worktreePath, "rev-parse", "--verify", "HEAD"]); - return true; - } catch { - return false; - } - }, - }; - - if (plan.usedExistingBranch) { - // Existing branch — check it out into a fresh worktree. Remote-tracking - // refs need explicit --track + -b so the worktree gets a real local - // branch, not detached HEAD. - await addWorktreeWithSparseCheckout({ - git, - worktreeArgs: - plan.startPoint.kind === "remote-tracking" - ? [ - "--track", - "-b", - plan.branch, - worktreePath, - plan.startPoint.remoteShortName, - ] - : [ - worktreePath, - plan.startPoint.kind === "head" - ? "HEAD" - : plan.startPoint.shortName, - ], - worktreePath, - sparsePaths, - logPrefix: "[workspaces.create]", - hookTolerance, - }); - return; - } - - // New branch from start point. --no-track keeps `git pull` and - // ahead/behind counts pointing at the branch's own upstream once - // push.autoSetupRemote sets it on first push. - const startPointArg = - plan.startPoint.kind === "head" - ? "HEAD" - : plan.startPoint.kind === "remote-tracking" - ? plan.startPoint.remoteShortName - : plan.startPoint.shortName; - await addWorktreeWithSparseCheckout({ - git, - worktreeArgs: [ - "--no-track", - "-b", - plan.branch, - worktreePath, - startPointArg, - ], - worktreePath, - sparsePaths, - logPrefix: "[workspaces.create]", - hookTolerance, - }); -} - async function recordBaseBranchConfig(args: { git: GitClient; worktreePath: string; @@ -558,6 +447,8 @@ async function registerLocalWorkspace(args: { name: string; branch: string; worktreePath: string; + rootPath?: string | null; + repos?: CreatedWorkspaceRepo[]; taskId: string | undefined; tags: string[] | undefined; rollbackWorktree: () => Promise; @@ -570,8 +461,17 @@ async function registerLocalWorkspace(args: { id: args.id, projectId: args.projectId, worktreePath: args.worktreePath, + rootPath: args.rootPath ?? null, branch: args.branch, name: args.name, + repos: args.repos?.map((repo) => ({ + position: repo.position, + projectId: repo.projectId, + folder: repo.folder, + worktreePath: repo.worktreePath, + branch: repo.branch, + baseBranch: repo.baseBranch, + })), taskId: args.taskId ?? null, createdByUserId: ctx.userId ?? null, tags: args.tags, @@ -615,6 +515,39 @@ export const workspacesRouter = router({ const localProject = requireLocalProject(ctx, input.projectId); const repoPath = requireProjectRepoPath(localProject); + const projectFolderList = effectiveProjectFolders( + ctx, + input.projectId, + input.extraProjectIds, + ); + const isMultiRepo = projectFolderList.length > 1; + if (isMultiRepo && input.pr !== undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "A pull request checks out one repository; this project has several folders", + }); + } + if (isMultiRepo && input.worktreePath) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Adopting an existing worktree covers one repository; this project has several folders", + }); + } + if (isMultiRepo && input.checkout === "local") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "A local workspace shares the project's own checkout, which is a single repository", + }); + } + const progressLines: string[] = []; + const reportProgress = (line: string) => { + progressLines.push(line); + console.log(`[workspaces.create] ${line}`); + }; + // Kick off AI naming when the user supplied a prompt but no // workspace name. The worktree add and registration run with an // immediately-available branch while the LLM call proceeds in @@ -1078,6 +1011,55 @@ export const workspacesRouter = router({ if (existing) { workspaceRow = existing; alreadyExists = true; + } else if (isMultiRepo) { + const containerPath = safeResolveWorktreePath( + localProject.id, + resolvedBranch, + worktreeBaseDir, + ); + mkdirSync(dirname(containerPath), { recursive: true }); + + const resolvedFolders = await resolveFolderProjects({ + ctx, + folders: projectFolderList, + onProgress: reportProgress, + }); + const repos = await createMultiRepoWorktrees({ + ctx, + containerPath, + folders: resolvedFolders, + primaryPlan: plan, + baseBranch: input.baseBranch, + onProgress: reportProgress, + }); + const primaryRepo = repos[0]; + if (!primaryRepo) { + await rollbackMultiRepoWorktrees(ctx, repos, containerPath); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Multi-repo workspace produced no primary checkout", + }); + } + worktreePath = primaryRepo.worktreePath; + resolvedBranch = primaryRepo.branch; + + workspaceRow = await registerLocalWorkspace({ + ctx, + id: input.id, + projectId: input.projectId, + name: input.name ?? resolvedBranch, + branch: resolvedBranch, + worktreePath, + rootPath: containerPath, + repos, + taskId: input.taskId, + tags: input.tags, + rollbackWorktree: () => + rollbackMultiRepoWorktrees(ctx, repos, containerPath), + }); + // Renaming the branch would only rename the primary's, leaving + // the other checkouts on the old name. + aiCanRenameBranch = false; } else { // Adopt at any path git already knows for this branch — git // refuses a second checkout of the same branch, so falling @@ -1316,6 +1298,7 @@ export const workspacesRouter = router({ ctx, workspaceId: workspaceRow.id, ...(chainAgent ? { chainCommand: chainAgent.fullCommand } : {}), + ...(progressLines.length > 0 ? { preamble: progressLines } : {}), }); if (warning) { console.warn(`[workspaces.create] setup warning: ${warning}`); @@ -1562,3 +1545,4 @@ export const workspacesRouter = router({ const createWorkspacesCaller = createCallerFactory(workspacesRouter); export { generateWorkspaceNamesFromPrompt as _aiNamesGenerator }; +export { addBranchWorktree, type BranchSourcePlan }; diff --git a/packages/host-service/src/workspaces/local-workspace-store.ts b/packages/host-service/src/workspaces/local-workspace-store.ts index 63e14a7b5e9..6bbe0edcfc2 100644 --- a/packages/host-service/src/workspaces/local-workspace-store.ts +++ b/packages/host-service/src/workspaces/local-workspace-store.ts @@ -11,7 +11,7 @@ import { } from "@superset/shared/workspace-tags"; import { and, eq, inArray } from "drizzle-orm"; import type { HostDb } from "../db"; -import { workspaces, workspaceTags } from "../db/schema"; +import { workspaceRepos, workspaces, workspaceTags } from "../db/schema"; import type { EventBus } from "../events"; import type { WorkspaceSnapshot } from "../events/types"; import type { ApiClient } from "../types"; @@ -113,6 +113,7 @@ export function toWorkspaceSnapshot( branch: row.branch, type: row.type, worktreePath: row.worktreePath, + rootPath: row.rootPath, taskId: row.taskId, createdByUserId: row.createdByUserId, createdAt: row.createdAt, @@ -228,17 +229,34 @@ export function getLocalWorkspace( return db.query.workspaces.findFirst({ where: eq(workspaces.id, id) }).sync(); } +export interface InsertLocalWorkspaceRepo { + position: number; + projectId: string; + folder: string; + worktreePath: string; + branch: string; + baseBranch?: string | null; +} + export interface InsertLocalWorkspaceValues { id?: string; /** Null for project-less "session" workspaces. */ projectId: string | null; + /** Always the primary checkout. */ worktreePath: string; + /** The multi-repo container; null or omitted otherwise. */ + rootPath?: string | null; branch: string; name: string; type?: "local" | "worktree" | "session"; taskId?: string | null; createdByUserId?: string | null; tags?: string[]; + /** + * Omitted for single-repo workspaces — the backfill materializes their + * position-0 row. + */ + repos?: InsertLocalWorkspaceRepo[]; } /** @@ -258,6 +276,7 @@ export function insertLocalWorkspace( id, projectId: values.projectId, worktreePath: values.worktreePath, + rootPath: values.rootPath ?? null, branch: values.branch, name: values.name, type: values.type ?? "worktree", @@ -267,6 +286,23 @@ export function insertLocalWorkspace( updatedAt: now, }) .run(); + if (values.repos?.length) { + tx.insert(workspaceRepos) + .values( + values.repos.map((repo) => ({ + id: randomUUID(), + workspaceId: id, + position: repo.position, + projectId: repo.projectId, + folder: repo.folder, + worktreePath: repo.worktreePath, + branch: repo.branch, + baseBranch: repo.baseBranch ?? null, + createdAt: now, + })), + ) + .run(); + } if (tags.length > 0) { // Adoption paths don't stamp the row's creator, but the tags are // still the acting user's — never let them fall through as public. diff --git a/packages/host-service/src/workspaces/workspace-repos.ts b/packages/host-service/src/workspaces/workspace-repos.ts new file mode 100644 index 00000000000..9163838aedc --- /dev/null +++ b/packages/host-service/src/workspaces/workspace-repos.ts @@ -0,0 +1,180 @@ +import { basename } from "node:path"; +import { eq, inArray } from "drizzle-orm"; +import type { HostDb } from "../db"; +import { projects, workspaceRepos, workspaces } from "../db/schema"; + +export interface WorkspaceRepo { + /** Null for the primary synthesized from a workspace with no rows yet. */ + id: string | null; + workspaceId: string; + position: number; + projectId: string; + folder: string; + worktreePath: string; + branch: string; + baseBranch: string | null; +} + +/** + * A workspace's checkouts in position order, primary first. A workspace with + * no rows of its own reads as the single checkout its columns already + * describe, so callers never branch on whether the backfill has run. + */ +export function listWorkspaceRepos( + db: HostDb, + workspaceId: string, +): WorkspaceRepo[] { + const rows = db + .select() + .from(workspaceRepos) + .where(eq(workspaceRepos.workspaceId, workspaceId)) + .all() + .sort((a, b) => a.position - b.position); + if (rows.length > 0) { + return rows.map((row) => ({ + id: row.id, + workspaceId: row.workspaceId, + position: row.position, + projectId: row.projectId, + folder: row.folder, + worktreePath: row.worktreePath, + branch: row.branch, + baseBranch: row.baseBranch, + })); + } + + const workspace = db + .select({ + projectId: workspaces.projectId, + worktreePath: workspaces.worktreePath, + branch: workspaces.branch, + }) + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + if (!workspace?.projectId) return []; + const project = db + .select({ repoPath: projects.repoPath }) + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); + return [ + { + id: null, + workspaceId, + position: 0, + projectId: workspace.projectId, + folder: basename(project?.repoPath ?? workspace.worktreePath), + worktreePath: workspace.worktreePath, + branch: workspace.branch, + baseBranch: null, + }, + ]; +} + +export function findWorkspaceRepo( + db: HostDb, + workspaceId: string, + repo?: string | null, +): WorkspaceRepo | undefined { + const repos = listWorkspaceRepos(db, workspaceId); + if (repo == null || repo === "") return repos[0]; + return repos.find( + (candidate) => candidate.folder === repo || candidate.id === repo, + ); +} + +export interface WorkspaceRepoDescriptor { + folder: string; + projectId: string; + /** `owner/name` when the repo has a parsed GitHub remote, else its folder. */ + repository: string | null; + branch: string; + base: string | null; + path: string; +} + +interface WorkspaceRowForRepos { + id: string; + projectId: string | null; + worktreePath: string; + branch: string; +} + +export function describeWorkspaceReposByWorkspaceId( + db: HostDb, + rows: WorkspaceRowForRepos[], +): Map { + const byWorkspace = new Map(); + if (rows.length === 0) return byWorkspace; + + const repoRows = db + .select() + .from(workspaceRepos) + .where( + inArray( + workspaceRepos.workspaceId, + rows.map((row) => row.id), + ), + ) + .all() + .sort((a, b) => a.position - b.position); + + const projectIds = new Set(); + for (const repo of repoRows) projectIds.add(repo.projectId); + for (const row of rows) if (row.projectId) projectIds.add(row.projectId); + const projectById = new Map( + projectIds.size === 0 + ? [] + : db + .select({ + id: projects.id, + repoPath: projects.repoPath, + repoOwner: projects.repoOwner, + repoName: projects.repoName, + }) + .from(projects) + .where(inArray(projects.id, [...projectIds])) + .all() + .map((project) => [project.id, project] as const), + ); + const repositoryOf = (projectId: string, fallback: string): string | null => { + const project = projectById.get(projectId); + if (project?.repoOwner && project.repoName) { + return `${project.repoOwner}/${project.repoName}`; + } + return project ? basename(project.repoPath) : fallback; + }; + + for (const repo of repoRows) { + const list = byWorkspace.get(repo.workspaceId) ?? []; + list.push({ + folder: repo.folder, + projectId: repo.projectId, + repository: repositoryOf(repo.projectId, repo.folder), + branch: repo.branch, + base: repo.baseBranch, + path: repo.worktreePath, + }); + byWorkspace.set(repo.workspaceId, list); + } + + for (const row of rows) { + if (byWorkspace.has(row.id) || !row.projectId) continue; + const folder = basename( + projectById.get(row.projectId)?.repoPath ?? row.worktreePath, + ); + byWorkspace.set(row.id, [ + { + folder, + projectId: row.projectId, + repository: repositoryOf(row.projectId, folder), + branch: row.branch, + base: null, + path: row.worktreePath, + }, + ]); + } + + return byWorkspace; +} diff --git a/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts b/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts new file mode 100644 index 00000000000..277e9ceb506 --- /dev/null +++ b/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts @@ -0,0 +1,369 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, readdirSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { eq } from "drizzle-orm"; +import { + projectFolders, + workspaceRepos, + workspaces, +} from "../../src/db/schema"; +import { runMultiRepoBackfill } from "../../src/runtime/multi-repo-backfill"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { createGitFixture, type GitFixture } from "../helpers/git-fixture"; +import { seedProject, seedWorkspace } from "../helpers/seed"; + +interface Scenario { + host: TestHost; + primary: GitFixture; + secondary: GitFixture; + projectId: string; + secondaryProjectId: string; + dispose(): Promise; +} + +async function createScenario(): Promise { + const host = await createTestHost(); + const primary = await createGitFixture(); + const secondary = await createGitFixture(); + const { id: projectId } = seedProject(host, { repoPath: primary.repoPath }); + const { id: secondaryProjectId } = seedProject(host, { + repoPath: secondary.repoPath, + }); + return { + host, + primary, + secondary, + projectId, + secondaryProjectId, + dispose: async () => { + await host.dispose(); + primary.dispose(); + secondary.dispose(); + }, + }; +} + +let scenario: Scenario | null = null; + +afterEach(async () => { + await scenario?.dispose(); + scenario = null; +}); + +function repoRows(host: TestHost, workspaceId: string) { + return host.db + .select() + .from(workspaceRepos) + .where(eq(workspaceRepos.workspaceId, workspaceId)) + .all() + .sort((a, b) => a.position - b.position); +} + +describe("multi-repo project folders", () => { + test("a single-folder project still creates exactly today's workspace", async () => { + scenario = await createScenario(); + const { host, primary, projectId } = scenario; + + const result = await host.trpc.workspaces.create.mutate({ + projectId, + name: "solo", + branch: "feature/solo", + skipBranchPrefix: true, + runSetup: false, + }); + + const row = host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result.workspace.id)) + .get(); + expect(row?.rootPath).toBeNull(); + expect(basename(row?.worktreePath ?? "")).toBe("solo"); + expect(existsSync(join(row?.worktreePath ?? "", ".git"))).toBe(true); + expect(readdirSync(dirname(row?.worktreePath ?? ""))).toEqual(["solo"]); + + runMultiRepoBackfill({ db: host.db }); + const repos = repoRows(host, result.workspace.id); + expect(repos).toHaveLength(1); + expect(repos[0]?.position).toBe(0); + expect(repos[0]?.worktreePath).toBe(row?.worktreePath ?? ""); + expect(repos[0]?.projectId).toBe(projectId); + + const worktrees = await primary.git.raw([ + "worktree", + "list", + "--porcelain", + ]); + expect(worktrees).toContain(row?.worktreePath ?? ""); + }); + + test("two folders produce one container with a worktree per folder", async () => { + scenario = await createScenario(); + const { host, primary, secondary, projectId } = scenario; + + const added = await host.trpc.project.folders.add.mutate({ + projectId, + repoPath: secondary.repoPath, + }); + expect(added.folder.position).toBe(1); + + const result = await host.trpc.workspaces.create.mutate({ + projectId, + name: "both", + branch: "feature/both", + skipBranchPrefix: true, + runSetup: false, + }); + + const row = host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result.workspace.id)) + .get(); + const container = row?.rootPath ?? ""; + expect(container).not.toBe(""); + + const repos = repoRows(host, result.workspace.id); + expect(repos).toHaveLength(2); + expect(repos.map((repo) => repo.folder)).toEqual([ + basename(primary.repoPath), + basename(secondary.repoPath), + ]); + expect(repos.map((repo) => repo.branch)).toEqual([ + "feature/both", + "feature/both", + ]); + expect(row?.worktreePath).toBe(repos[0]?.worktreePath ?? ""); + expect(row?.worktreePath).not.toBe(container); + for (const repo of repos) { + expect(repo.worktreePath).toBe(join(container, repo.folder)); + expect(existsSync(join(repo.worktreePath, ".git"))).toBe(true); + } + + for (const fixture of [primary, secondary]) { + const list = await fixture.git.raw(["worktree", "list", "--porcelain"]); + expect(list).not.toContain(`worktree ${container}\n`); + } + }); + + test("deleting a multi-repo workspace removes every worktree and the container", async () => { + scenario = await createScenario(); + const { host, primary, secondary, projectId } = scenario; + + await host.trpc.project.folders.add.mutate({ + projectId, + repoPath: secondary.repoPath, + }); + const result = await host.trpc.workspaces.create.mutate({ + projectId, + name: "doomed", + branch: "feature/doomed", + skipBranchPrefix: true, + runSetup: false, + }); + const container = + host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result.workspace.id)) + .get()?.rootPath ?? ""; + + const destroyed = await host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: result.workspace.id, + deleteBranch: true, + }); + expect(destroyed.success).toBe(true); + expect(destroyed.worktreeRemoved).toBe(true); + expect(existsSync(container)).toBe(false); + + for (const fixture of [primary, secondary]) { + const list = await fixture.git.raw(["worktree", "list", "--porcelain"]); + expect(list).not.toContain(container); + const branches = await fixture.git.raw(["branch", "--list"]); + expect(branches).not.toContain("feature/doomed"); + } + }); + + test("extraProjectIds check out beside the primary without touching the project", async () => { + scenario = await createScenario(); + const { host, secondary, projectId, secondaryProjectId } = scenario; + + const result = await host.trpc.workspaces.create.mutate({ + projectId, + extraProjectIds: [secondaryProjectId], + name: "adhoc", + branch: "feature/adhoc", + skipBranchPrefix: true, + runSetup: false, + }); + + const repos = repoRows(host, result.workspace.id); + expect(repos.map((repo) => repo.projectId)).toEqual([ + projectId, + secondaryProjectId, + ]); + expect(repos[1]?.folder).toBe(basename(secondary.repoPath)); + const folders = await host.trpc.project.folders.list.query({ projectId }); + expect(folders.folders).toHaveLength(1); + }); + + test("git procedures address a secondary checkout through `repo`", async () => { + scenario = await createScenario(); + const { host, secondary, projectId } = scenario; + + await host.trpc.project.folders.add.mutate({ + projectId, + repoPath: secondary.repoPath, + }); + const result = await host.trpc.workspaces.create.mutate({ + projectId, + name: "addressed", + branch: "feature/addressed", + skipBranchPrefix: true, + runSetup: false, + }); + const repos = repoRows(host, result.workspace.id); + const secondaryFolder = repos[1]?.folder ?? ""; + + const primaryStatus = await host.trpc.git.getBranchSyncStatus.query({ + workspaceId: result.workspace.id, + }); + const secondaryStatus = await host.trpc.git.getBranchSyncStatus.query({ + workspaceId: result.workspace.id, + repo: secondaryFolder, + }); + expect(primaryStatus.currentBranch).toBe("feature/addressed"); + expect(secondaryStatus.currentBranch).toBe("feature/addressed"); + + await expect( + host.trpc.git.getBranchSyncStatus.query({ + workspaceId: result.workspace.id, + repo: "nope", + }), + ).rejects.toThrow(/no repo "nope"/); + }); +}); + +describe("multi-repo backfill", () => { + test("materializes position-0 rows once and stays a no-op after", async () => { + scenario = await createScenario(); + const { host, primary, projectId } = scenario; + const { id: workspaceId } = seedWorkspace(host, { + projectId, + worktreePath: join(primary.repoPath, ".worktrees", "legacy"), + branch: "legacy", + }); + + const first = runMultiRepoBackfill({ db: host.db }); + expect(first.repos).toBe(1); + + const folders = host.db + .select() + .from(projectFolders) + .where(eq(projectFolders.projectId, projectId)) + .all(); + expect(folders).toHaveLength(1); + expect(folders[0]?.position).toBe(0); + expect(folders[0]?.repoPath).toBe(primary.repoPath); + expect(folders[0]?.folder).toBe(basename(primary.repoPath)); + + const repos = repoRows(host, workspaceId); + expect(repos).toHaveLength(1); + expect(repos[0]?.worktreePath).toBe( + join(primary.repoPath, ".worktrees", "legacy"), + ); + expect(repos[0]?.branch).toBe("legacy"); + + const second = runMultiRepoBackfill({ db: host.db }); + expect(second).toEqual({ folders: 0, repos: 0 }); + expect(repoRows(host, workspaceId)).toHaveLength(1); + expect( + host.db + .select() + .from(projectFolders) + .where(eq(projectFolders.projectId, projectId)) + .all(), + ).toHaveLength(1); + }); +}); + +describe("project folder CRUD", () => { + test("add, rename, promote and remove keep positions contiguous", async () => { + scenario = await createScenario(); + const { host, primary, secondary, projectId } = scenario; + + // The primary is materialized from the project row on first mutation. + const { folder: added } = await host.trpc.project.folders.add.mutate({ + projectId, + repoPath: secondary.repoPath, + folder: "api", + }); + expect(added.position).toBe(1); + + const listed = await host.trpc.project.folders.list.query({ projectId }); + expect(listed.folders.map((folder) => folder.position)).toEqual([0, 1]); + expect(listed.folders[0]?.repoPath).toBe(primary.repoPath); + + const third = await createGitFixture(); + try { + const { folder: duplicate } = await host.trpc.project.folders.add.mutate({ + projectId, + repoPath: third.repoPath, + folder: "api", + }); + expect(duplicate.folder).toBe("api-2"); + + await expect( + host.trpc.project.folders.rename.mutate({ + projectId, + folderId: duplicate.id, + folder: "api", + }), + ).rejects.toThrow(/already called "api"/); + await expect( + host.trpc.project.folders.rename.mutate({ + projectId, + folderId: duplicate.id, + folder: "../escape", + }), + ).rejects.toThrow(/single directory name/); + + const renamed = await host.trpc.project.folders.rename.mutate({ + projectId, + folderId: duplicate.id, + folder: "worker", + }); + expect(renamed.folder.folder).toBe("worker"); + + const primaryFolder = listed.folders[0]; + await expect( + host.trpc.project.folders.remove.mutate({ + projectId, + folderId: primaryFolder?.id ?? "", + }), + ).rejects.toThrow(/primary folder/); + + const promoted = await host.trpc.project.folders.setPrimary.mutate({ + projectId, + folderId: added.id, + }); + expect(promoted.folders.map((folder) => folder.folder)).toEqual([ + "api", + primaryFolder?.folder ?? "", + "worker", + ]); + const project = await host.trpc.project.get.query({ projectId }); + expect(project?.repoPath).toBe(secondary.repoPath); + + const afterRemove = await host.trpc.project.folders.remove.mutate({ + projectId, + folderId: duplicate.id, + }); + expect(afterRemove.folders.map((folder) => folder.position)).toEqual([ + 0, 1, + ]); + } finally { + third.dispose(); + } + }); +}); diff --git a/packages/host-service/test/integration/no-snapshot-fields-for-queries.test.ts b/packages/host-service/test/integration/no-snapshot-fields-for-queries.test.ts index 77a78b683fe..9370f529780 100644 --- a/packages/host-service/test/integration/no-snapshot-fields-for-queries.test.ts +++ b/packages/host-service/test/integration/no-snapshot-fields-for-queries.test.ts @@ -26,6 +26,10 @@ const ALLOWLIST = new Set([ // (avatar/GitHub link), never for query routing. "projects/local-project-store.ts", + // Snapshot consumer: labels each of a workspace's checkouts for display + // ("owner/name" in `ws get`), never for query routing. + "workspaces/workspace-repos.ts", + // Resolver itself: mentions field names in JSDoc, no member reads. "trpc/router/workspace-creation/shared/project-helpers.ts", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b46d5527aa9..e2f390c44d1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -208,6 +208,11 @@ export const FEATURE_FLAGS = { * sees it, so a new build ships with it dark and no release widens it. */ MOBILE_LIVE_ACTIVITY: "mobile-live-activity", + /** + * Gates the UI for giving a project more than one repository. The host + * serves `project.folders.*` either way. + */ + MULTI_REPO_PROJECTS: "multi-repo-projects", } as const; /** From ec04d54216f9d64a8d96e01b22e461deef1b4f0a Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 21 Sep 2026 07:40:16 +0530 Subject: [PATCH 02/14] docs: drop the multi-repo research pages from the branch They are published as Superset pages and linked from the PR. Keeping ~2,900 lines of HTML in the diff buries the code, and the implementation page's claim that file read/write is unaffected was disproved by the workspace-root fix in this branch. Claude-Session: https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon --- docs/pages/multi-repo-competitive-scan.html | 945 ------------------- docs/pages/multi-repo-implementation.html | 996 -------------------- 2 files changed, 1941 deletions(-) delete mode 100644 docs/pages/multi-repo-competitive-scan.html delete mode 100644 docs/pages/multi-repo-implementation.html diff --git a/docs/pages/multi-repo-competitive-scan.html b/docs/pages/multi-repo-competitive-scan.html deleted file mode 100644 index 244a767857d..00000000000 --- a/docs/pages/multi-repo-competitive-scan.html +++ /dev/null @@ -1,945 +0,0 @@ - - - - - -Multi-repo workspaces: the competitive scan - - - - -
-
-
Competitive scan · companion to the multi-repo projects PRD
-

Every piece of this exists.
Nobody has assembled it.

-

- Ten tools were checked for how they handle work that spans several repositories. VS Code has - shown per-repo diffs since 2017. Capy and Factory isolate every task in its own VM. Codex has - a project-level folder list with a primary. DevSwarm has per-repo pull requests. Not one of - them has all four at once — and the tools closest to being an agent workspace are the ones - furthest from the git surfaces. That gap is the opening, and it decides what our v0 is. -

-
- 17 Sep 2026 · v2, claims verified - Conductor · Cursor · Codex · Kilo · Devin · DevSwarm - PRD: docs/prd/multi-repo-projects.md -
-
-
- -
- -
-

The finding

-

Three things the scan settled.

-
-
- 01 -

The cheap version is now table stakes

-

- "One agent sees N folders, git stays on one repo" is what Conductor, Cursor and Codex - all ship today. Building it is days of work and buys no differentiation — but not - having it is now a visible gap. -

-
-
- 02 -

Per-repo diffs aren't novel — the editors have had them for years

-

- VS Code's Source Control view groups changes by repository in a multi-root workspace, with - independent commits per repo; Cursor and Kilo inherit it. DevSwarm goes further with per-repo - PRs, bought with a submodule bundle. What's missing everywhere is pairing that with a - project-owned repo list and real isolation. -

-
-
- 03 -

Their isolation is a half-truth

-

- Codex worktrees cover the primary repo only; secondaries are shared directories that - parallel threads trample. Conductor links live worktrees another agent may be driving. - We already have the machinery to not do this. -

-
-
-
- -
-

Four things called "multi-repo"

-

Most of the disagreement in this space is a vocabulary problem.

- -

- Nine tools now claim multi-repo support and they mean four different things. Sorting them - first makes the rest of the page smaller, and it's why the matrix below only covers tiers 2 - and 3 — comparing a tier-1 tool on "per-repo diff" scores it ○ for a capability it never - set out to have. -

- -
-
- 1 -
-

Multi-repo access — one task, one repo

-

- The agent is entitled to many repositories; any individual task still targets one. - Capy: you pick repos when installing the GitHub App, then each task gets - its own branch capy/<task-slug>-<id> and its own PR. Devin - bulk-indexes repos and groups sessions by target repository. Useful, and not the problem - this PRD is about. -

-
-
-
- 2 -
-

Multi-repo context — one agent sees N folders, git owns one

-

- Where almost everyone is: VS Code multi-root (the original), - Cursor (inherits it), Codex (primary folder + secondaries), - Conductor (/add-dir), Kilo (projects in one panel). - The agent reads and writes across folders; every git surface stays anchored to one repo. -

-
-
-
- 3 -
-

Multi-repo workspace — N checkouts, per-repo git surfaces

-

- DevSwarm, alone, via a submodule bundle. This is the tier the PRD is - aiming at and where our cut 2 lands. -

-
-
-
- 4 -
-

Coordinated fan-out — N agents, one per repo, N PRs

-

- Factory Missions: "run coordinated changes across dozens of - repositories… decompose large projects into independent subtasks and assign each to a - dedicated Droid." Orthogonal to a shared workspace — it's the answer when the change - is mechanical and repeats across many repos, not when one feature threads through three. - Worth knowing we could reach it later by pointing automations at a project. -

-
-
-
-
- -
-

The matrix

-

Seven capabilities across tiers 2 and 3, and where our v0 lands.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CapabilityVS Codetier 2Cursortier 2Codextier 2Conductortier 2Kilotier 2Devintier 1Capytier 1Factorytier 4DevSwarmtier 3Superset v0
Project owns a repo listdefine once, inherit per workspace◐hand-kept .code-workspace◐inherited●Source folders + Primary○◐projects in a panel○○App access scope, not a workspace○no public evidence●submodule bundle●over local projects
One workspace spans N repos●●●○N workspaces, then link○separate projects●multi-root virtual FS○one task, one repo◐"staged checkout" claimed by a review, not their docs●●
No two workspaces share a checkoutoutcome, not mechanism — worktree on a device, the sandbox itself in cloud○○○primary only; secondaries shared○links live worktrees◐per project, not per workspace●session VM●"its own VM… no shared filesystem"●sandboxed per droid●●
Same branch name where freeconvenience, not a constraint — see open questions——○secondaries have no branch○○——one repo per task○◐own branches, name never stated◐best effort, bump per repo
Agent reaches all repos with no per-folder permission setup●all roots open●◐sandbox roots; open bug #35260○/add-dir per session○●——●●repos are children of cwd
Per-repo diff in the UI●SCM view groups changes by repository●inherited SCM view○primary cwd only○other repo's own workspace◐VS Code's SCM, not Agent Manager○no evidence—○no evidence●"unified file list… filter to a member"◐cut 2 — PR-1
Ship surface knows about N reposyou pick which to open PRs for◐commit per repo; PRs need an extension◐inherited○"commit each independently"○○○no evidence—●Missions ship multiple PRs — by fan-out●own PRs, merge independently◐cut 2 — PR-2
-
-
- ● ships it - ◐ partial / planned - ○ doesn't - — not applicable at that tier - scroll sideways → -
-

- The row that changed the conclusion is VS Code's. Its Source Control view has - grouped changes by repository in a multi-root workspace for years, with independent commits per - repo — so "per-repo diff" is not novel, and Cursor and Kilo inherit it for free by being VS Code. - What none of the ten has is the combination: a project-owned repo list, isolation per - workspace, per-repo diffs and a per-repo ship surface in one product. Every individual capability - exists somewhere; the assembly doesn't. -

-
- -
-

Tool by tool

-

What each one actually does, in their words.

-
- -
-

Conductor

links workspaces
-

one repo per workspace · /add-dir

-
"Add each directory to Conductor… Create a workspace for each directory… In one workspace, enter /add-dir and select the other workspaces you'd like to link together."
-

- Workspaces live at ~/conductor/workspaces/<repo name>/<workspace name> - — one worktree, one branch, one diff viewer. Multi-repo is Claude Code's own - --add-dir flag surfaced as a slash command. Their recommended answer to - multi-repo is actually "use a monorepo": "Conductor supports monorepos out of the box." -

-

Gap: N sidebar rows per task, uncoordinated branch names, edits to a linked - repo land in that other workspace's diff viewer — and per Anthropic's docs, - --add-dir loads none of the linked repo's CLAUDE.md by default.

-
- -
-

Cursor

multi-root editor
-

VS Code .code-workspace · Apr 2026

-
"A single agent session can now target a reusable workspace made of multiple folders."
-

- Inherited straight from VS Code: a workspace file lists folder roots, indexing runs - per root, @Codebase spans them. Genuinely good for context. Git stays - per-folder and the workspace file is yours to maintain by hand. -

-

Gap: no project-level definition, no isolation, no git story beyond - what VS Code already gave you.

-
- -
-

Codex

primary-folder anchor
-

multi-folder projects · 23 Jul 2026, build 26.715

-
"New chats, Git operations, and discovery of AGENTS.md, skills and config.toml use the primary folder; secondary folders remain available for file search, reading, and editing."
-

- The closest to our design: a project is an ordered folder list with one marked - Primary, and the primary owns every git surface. Secondaries are plain - existing directories — not worktrees — shared across every thread, with .git/ - read-only and the diff panel scoped to the primary cwd. -

-

Gap: worktree mode forks the primary only, so two parallel threads edit - the same secondary directory. #35260 - — hand-made sibling worktrees don't even reach the sandbox's writable roots. Open, no reply.

-
- -
-

Kilo

projects in a panel
-

Multi-project Agent Manager · experimental, off by default

-
"Each repository has its own worktrees, sessions, sections, selection and state… so sessions and worktrees are not mixed between repositories."
-

- Explicitly not a unified workspace — several repos shown as separate projects in - one panel, each with state in its own .kilo/agent-manager.json. A maintainer - has filed to formally forbid pointing it at a non-git parent folder, because users keep - trying and Agent Manager mishandles it. -

-

Gap: grouping, not unification. One agent never spans two repos.

-
- -
-

Devin

indexing + grouping
-

multi-root virtual FS · thinnest evidence of the six

-

- Their changelog confirms "support for working on multi-root workspaces containing virtual - filesystem folders," plus bulk repo indexing and sessions groupable by target repository. - The visible investment is in knowing about many repos, not in checking several - out together. -

-

Unknown: no public documentation of a project repo list, shared branch or - per-repo ship flow — treat the empty cells as unevidenced, not as confirmed absences.

-
- -
-

DevSwarm

closest to the ambition
-

git submodules + worktrees

-
"Every repository inside a Multi-Repo is backed by Git submodules… each member is checked out as a Git worktree that shares the underlying repo's object store instead of creating another full clone."
-

- The only product with the full shape, and the mechanics are specific enough to believe: - "each repository keeps its own history, its own remote, its own branches, its own pull - requests." The review surface is real too — "the unified file list shows every - repo's diff in one place. Filter to the api member to scrutinize the contract - change on its own." -

-

Caveat: vendor documentation, not verified hands-on — and whether all members - share one branch name is never stated. Submodules impose a parent repo and ongoing SHA - maintenance, which is the tax we should be able to avoid.

-
- -
-
- -
-

Why they all stop

-

The hard part isn't the checkouts. It's everything keyed by one workspace.

- -

- Cutting N worktrees is easy. What's expensive is that a single-repo product accumulates - state keyed by workspace, on the safe assumption that a workspace is one repo — and - that the agent quietly assumes it too. Five such places were found: three in our - code, two in Claude Code's documented behaviour. The last two are the ones the PRD doesn't - mention and are the reason the container folder isn't free. -

- -
-
- 1 -
-

The host-service status cache would serve the wrong repo's diff

-

- Keyed Map<workspaceId, Map<baseBranch, Variant>>. Three repos in - one workspace usually share a base branch name, so they collide on one key and the last - snapshot wins. Silent wrong data, not a slowdown. -

-

- Scope, now verified: this is the host-service git router, which - serves remote and cloud workspaces. The desktop's own Changes surface is a separate, - fully path-based router — worktreePath: z.string(), validated against a - registered-worktrees table, cached by makeStatusCacheKey(worktreePath, branch). - For a local workspace the switcher never touches this cache at all. -

-

git-status-store.ts:31 · desktop changes/status.ts:25

-
-
-
- 2 -
-

The refresh queue would serialize the repos

-

- Map<workspaceId, WorkspaceQueue> with one active slot and a FIFO - queue — three repos refresh one after another. Fixed by the same change as above: - key on workspaceId + repoFolder. -

-

git-status-refresh-limiter.ts:31

-
-
-
- 3 -
-

Only one checkout would be watched

-

- One { workspaceId, worktreePath, watcher } per workspace, attached from - the row's single path. Without N watcher pairs plus refcounting, edits in the other - repos never mark the workspace dirty. The fiddliest of the four. -

-

git-watcher.ts

-
-
-
- 4 -
-

Every repo's AGENTS.md would go lazy

-

- Anthropic's docs, verbatim: "Claude also discovers CLAUDE.md and CLAUDE.local.md files - in subdirectories under your current working directory. Instead of loading them at launch, - they are included when Claude reads files in those subdirectories." -

-

- So opening the agent at a container folder demotes each repo's instructions from eager to - lazy — on this repo, that means starting without the i18n, comments and plugin rules until - something happens to touch a file. Codex has the identical problem and documents it. - Conductor's /add-dir route is worse still: those directories' CLAUDE.md files - are not loaded at all unless CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD - is set. -

-

mitigation: WS-6 root manifest, now mandatory rather than nice-to-have

-
-
-
- 5 -
-

Auto memory would fragment per workspace

-

- Also from Anthropic's docs: the auto-memory directory "is derived from the git - repository, so all worktrees and subdirectories within the same repo share one auto memory - directory. Outside a git repo, the project root is used instead." -

-

- A container folder is not a git repo, and its path is per-workspace. So every multi-repo - workspace would get its own empty memory directory instead of sharing the repo's — and - lose it on delete. Today every worktree of a repo shares one. This is a silent regression - in agent quality that no test would catch. -

-

mitigation: set CLAUDE_CODE_PROJECT_DIR_NAME at agent launch (needs Claude Code ≥ v2.1.234)

-
-
-
- -

- The good news is the diff plumbing itself is contained. Every host-service git procedure - resolves its path through one 31-line function, called from 24 sites — 21 of - them in a single file — all shaped identically. A repo switcher is an optional second argument, - not a rewrite. That matters because redefining the workspace path itself would touch - 1,756 non-test references, and refusing that one change is what keeps - everything else cheap. -

-
- -
-

Where we land

-

Two anchors instead of one is the whole trick.

- -

- Codex anchors git at the primary folder because it has one anchor to spend. Kilo's - maintainer wants to forbid non-git parent folders for the same reason: their workspace root - doubles as the git root, so pointing it at a container breaks worktrees and setup scripts. - Splitting the two fields dissolves the tension. -

- -
~/.superset/worktrees/<primaryProjectId>/avi/multi-repo/   ← rootPath: agent, terminal, file tree
-  superset/       .git → file into base repo   branch: avi/multi-repo   base: main   ← worktreePath
-  skills/         .git → file into base repo   branch: avi/multi-repo   base: main
-  homebrew-tap/   .git → file into base repo   branch: avi/multi-repo   base: master
- -
-
-

worktreePath

-
git anchor
-
    -
  • Always a real repository checkout — Kilo's objection never applies
  • -
  • Stays the primary, so all 1,702 references keep working untouched
  • -
  • Changes, Ship, PR status, status refresh: unchanged in v0
  • -
-
-
-

rootPath

-
human + agent anchor
-
    -
  • The container folder the person opens
  • -
  • Repos are children of cwd, so no --add-dir, no sandbox roots
  • -
  • Never passed to git, setup scripts or worktree operations — one-line invariant, cheap test
  • -
-
-
- -
-

- The resulting position: Codex's UX and its cheap git story, plus the - per-repo isolation Codex and Conductor both lack, plus a real branch in every repo — which - turns per-repo PRs from a redesign into an increment. -

-

- Individual developers keep hand-rolling this container as the "meta-repo" or - "repo-of-repos" pattern because it's the only way to get an agent that sees everything. - No product ships it. That gap is the opening. -

-
-
- -
-

What v0 is

-

Host-side only. No cloud schema change at all.

- -

- superset ws create --project already takes local project ids, and a local - project already means "one repo checked out at one path." So the Source-folders dialog is a - picker over things we already have — which deletes the most expensive requirements in the PRD - outright. -

- -
-
-

In

-
~8 of 62 requirements
-
    -
  • Source-folders list on the project, first is Primary (Codex's dialog, simplified)
  • -
  • One checkout per repo, isolated per workspace — worktrees on a device, the sandbox itself in cloud (WS-1)
  • -
  • Same branch name in each repo where it's free, bumped per repo where it isn't (WS-2, downgraded — see open questions)
  • -
  • Atomic create with rollback (WS-5)
  • -
  • Agent context kit: root manifest + SUPERSET_WORKSPACE_REPOS + CLAUDE_CODE_PROJECT_DIR_NAME (WS-6 — mandatory, see landmines 4 & 5)
  • -
  • Delete removes every worktree, then the root (WS-9)
  • -
  • Missing repos cloned on first use (WS-4)
  • -
  • Repeatable --project, repos array in ws get (CLI-2, CLI-4)
  • -
-
-
-

Out

-
deferred or deleted
-
    -
  • GitHub App Add-repositories dialog (PRJ-2 — deleted, not deferred)
  • -
  • Per-repository installation tokens (SEC-1 — folders are already cloned)
  • -
  • project_repositories in Postgres + migration (MIG-1)
  • -
  • Cloud sandboxes, environments, warm snapshots (all of §8.3)
  • -
  • Sidebar grouping by project (WS-8 — falls back to primary)
  • -
  • Per-repo setup scripts (WS-7)
  • -
-
-
- -

Then, immediately: the part only DevSwarm has

-

- PR-1 (repo switcher on Changes) and PR-2 (one PR per repo - against its own base). Both the Changes view and the file tree hang off a single variable each - — ChangesContent.tsx:22 and FilesView.tsx:149 — so the client side is - small. -

-

- The open question from v1 of this page is now answered, and it's good news: the desktop's - changes router is path-based end to end, keyed by - worktreePath and validated against a registered-worktrees table. So for local - workspaces, cut 2 is "register N worktree paths, pass the selected one" and landmines 1–2 - never fire. They still have to be paid for remote and cloud workspaces served by the - host-service router, and landmine 3 (one watcher per workspace) is unavoidable either way. -

-
- -
-

The verdict

-

Three cuts. The agent-context work is not optional in any of them.

- -
-

- Build the host-side primitive first and the cloud project side last — the - inverse of the PRD's milestone order. The PRD builds the web project flow (M3) first, which - front-loads the GitHub App dialog, a Postgres table and a migration. None of that is needed - to make multiple repositories feel native, because ws create --project already - takes local project ids. -

-
- -

Cut 1 — v0, days not weeks

-

- Source-folders list over local projects with a Primary. One isolated checkout per repo, - atomic create, delete-all. rootPath opens the agent and the file tree; - worktreePath stays the primary and owns every git surface, so all 1,756 references - keep working. Plus the agent context kit — root manifest, SUPERSET_WORKSPACE_REPOS, - CLAUDE_CODE_PROJECT_DIR_NAME — which landmines 4 and 5 promoted from polish to - prerequisite. Ships strictly ahead of Conductor and Codex on isolation, level on everything else. -

- -

Cut 2 — the differentiator

-

- Repo switcher on Changes, then a Ship surface that lists each repo with commits ahead and opens - a PR for the ones you check — against that repo's own base. Not "one action fires N PRs": - you pick. Cheap for local workspaces now that the desktop router is confirmed path-based; the - host-service cache and limiter get keyed by workspaceId + repoFolder for remote, - and the watcher becomes N pairs. Note this is not novel UX — VS Code has grouped changes by - repository for years, so the pattern is well-trodden and the risk is low. What's novel is having - it together with a project-owned list and per-workspace isolation. Cross-linked PR bodies - and workspace-name titles (PR-3) are the opinionated part and stay out until someone asks. -

- -

Cut 3 — cloud

-

- Exactly as the PRD stages it (§8.3, ENV-*). No competitive pressure here — not one of the six - ties multi-repo to a cloud environment model, so this is ours to schedule. -

- -

Four things to explicitly not do

-
    -
  • Don't redefine worktreePath as the container. 1,756 references, and it buys nothing cut 1 doesn't already give.
  • -
  • Don't use submodules. DevSwarm's bundle works but imposes a parent repo and SHA maintenance; a project-owned repo list gets the same result with no git-level coupling.
  • -
  • Don't build the GitHub App repo dialog for v0. PRJ-2 and SEC-1 are deleted by the local-projects picker, not deferred by it.
  • -
  • Never pass rootPath to git, setup scripts or worktree operations. One-line invariant, one cheap test — and the exact conflation a Kilo maintainer has an open issue to prevent.
  • -
- -

The one thing to check before committing

-

- A DevSwarm trial. Everything above assumes we can reach their capability without their - submodule tax. That assumption is load-bearing for cut 2 and it has not been tested. -

- -

Open, from review

-

- Three challenges from Avi on v2, two of which moved the plan: -

-
    -
  • - Worktrees shouldn't be enforced regardless of environment. Agreed, and the - matrix row is reworded to say so. The requirement is the outcome — no two workspaces - share a checkout — not the mechanism. On a device a worktree delivers it; in a cloud sandbox - the sandbox already is the boundary, so plain clones under /workspace are correct - and cheaper. The PRD already splits it this way (ENV-3); v1 of this page flattened it. -
  • -
  • - Is one branch name across repos necessary? No. Downgraded from a constraint - to a convenience. WS-2's "if the name is taken in any repo, bump it in all" - needs a probe of every member before create and produces a surprising rename; per-repo bumping - gives matching names almost always and breaks nothing when it doesn't. Cross-links live in PR - bodies, not in a branch-name search, so nothing downstream depends on it. -
  • -
  • - "One PR per repo from one action" feels opinionated. Fair, and it was my - phrasing overselling it — the PRD's PR-2 was already opt-in per repo. Reworded to "Ship - surface knows about N repos; you pick which to open." The genuinely opinionated pieces - (cross-linked bodies, workspace-name titles) are now explicitly out of scope. -
  • -
-
- -
-

Evidence quality

-

- Every quote on this page was re-checked against its primary source for v2. What changed: -

-
    -
  • - One quote was wrong and is gone. v1 attributed "A workspace is tied to - exactly one repository" to Conductor. That sentence does not appear in their documentation — - it was a summarisation artifact. The substance holds and is now carried by their actual - words ("create a workspace for each directory… then /add-dir") plus the - repo-keyed workspace path. -
  • -
  • - Two numbers were wrong and are corrected. 33 resolveWorktreePath - "call sites" was really 33 references including imports and type positions; there are - 24 actual calls. And worktreePath non-test references are - 1,756, not 1,702 — v1 used a filter that also dropped lines merely containing - the word "test". -
  • -
  • - DevSwarm was under-claimed. v1 marked their per-repo diff "claimed, not - verified". Their own documentation is specific and mechanical — submodules, worktrees sharing - an object store, a unified file list filterable per member — so the row is upgraded. Still - vendor documentation, still no hands-on trial. -
  • -
  • - Verified verbatim: the Cursor sentence, both Kilo sentences, the Codex - release note (build 26.715, 23 Jul 2026), and the Conductor three-step. Codex's read-only - .git/ is corroborated by several independent openai/codex issues and is a - general sandbox behaviour, not specific to secondary folders. -
  • -
  • - Devin remains the thinnest row. Multi-root support is confirmed from their - changelog; everything else is absence of evidence, and the matrix now says so rather than - scoring it. -
  • -
-

- Highest-value remaining check: 30 minutes in a DevSwarm trial. They are the - only competitor with the capability this plan treats as the differentiator, and whether their - submodule bundle is load-bearing or incidental changes how confident we should be that we can - beat it without one. -

-
- -
-

Sources

- -
- -
- Companion to Multi-repo projects PRD (docs/prd/multi-repo-projects.md). The PRD - defines the destination and a 33-requirement MVP; this page argues for a smaller first cut and a - different build order — host-side primitive first, cloud project side last. Pin a comment on any - element to push back. -
- -
- - diff --git a/docs/pages/multi-repo-implementation.html b/docs/pages/multi-repo-implementation.html deleted file mode 100644 index a75d8501c65..00000000000 --- a/docs/pages/multi-repo-implementation.html +++ /dev/null @@ -1,996 +0,0 @@ - - - - - -Multi-repo projects: the implementation - - - - -
-
-
Implementation · multi-repo projects · v1
-

Codex's model, with one difference
that changes what it's worth.

-

- A project becomes a primary folder plus any number of additional folders. Codex ships exactly - this and gives the primary a worktree, leaving the rest as shared directories two threads can - trample. We give every folder its own checkout — the difference between "the agent - can see your other repo" and "this workspace owns its own copy of all three." -

-

- The requirement is that outcome, not the mechanism. On a device a git worktree - delivers it. In a cloud sandbox the sandbox is already the isolation boundary, so plain clones - under /workspace are correct there and worktrees buy - nothing. This page is the device cut; read "worktree" below as "the device mechanism for one - isolated checkout." -

-
- 18 Sep 2026 - Device-only · behind a flag - Companion: the competitive scan -
-
-
- -
- -
-

The shape

-

One container, N checkouts, one branch name, git anchored on the primary.

- -

On a device — worktrees

-
~/.superset/worktrees/<primaryProjectId>/avi/multi-repo/      ← the container
-  superset/        worktree · branch avi/multi-repo · base main    ← primary · agent cwd · workspaces.worktreePath
-  skills/          worktree · branch avi/multi-repo · base main
-  homebrew-tap/    worktree · branch avi/multi-repo · base master
- -

- Each folder is a real git worktree cut from its own base repo, so their - .git files point into three unrelated repositories and none of them knows the - others exist. The container is a plain directory with no git in it. Nothing is a submodule and - there is no parent repo. -

- -

In a cloud sandbox — the folders, on a branch

-
/workspace/                                                   ← the container, one sandbox per workspace
-  superset/        clone · checked out on avi/multi-repo          ← primary · agent cwd
-  skills/          clone · checked out on avi/multi-repo
-  homebrew-tap/    clone · checked out on avi/multi-repo
- -

- Same container, same folders, same branch name — and no worktree anywhere. Put each repo in - its folder and git checkout -b. The next section is why that is not a shortcut but - the correct answer, and what drops out of the build because of it. -

- -
-

- The load-bearing decision: workspaces.worktreePath keeps pointing - at the primary checkout, not the container — a worktree on a device, a clone in a - sandbox. Either way it is still a git checkout, so every one of the 1,756 - non-test references to it keeps working untouched: Changes, Ship, PR status, status refresh, - delete. Multi-repo becomes additive instead of a rewrite. -

-
-
- -
-

Local and cloud

-

One model, two provisioning paths — and the cloud one is much shorter.

- -

- git worktree solves a problem only a device has: many working copies of one - repo on one machine. A sandbox holds exactly one workspace and therefore exactly one - working copy per repo, so the feature has no job there. Using it anyway would mean inventing a - base repo to cut worktrees from, purely to preserve a symmetry nothing benefits from. -

- -
-

- Everything above provisioning is shared, and that is what makes the split - safe: the container with N folders, the folder list copied at creation, one branch name, the - primary at position 0 owning the git surfaces, workspace_repos, the host-service - rows the sandbox seeds for itself, the folder picker, and the agent launch. The two paths - differ only in how a folder becomes a working copy. -

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
StepDeviceCloud sandbox
Get the repoMatch an existing local project by link, then by remote; otherwise clone to ~/.superset/repos/<owner>/<name> and create the local projectgit clone straight into /workspace/<folder>. A warm snapshot that already carries the repo shallow-fetches instead
Make the working copygit worktree add from the base repo into the containerNothing — the clone is the working copy
Get on the branchPart of worktree add -b, after checking the name is not already held by another worktree of that repogit checkout -b in the folder
Branch collisionsReal. The name may exist, or be held by another worktree — including a stale registration whose directory is gone, which is why creation prunes firstNone locally. The sandbox is fresh, so only the remote can collide, and that surfaces at push
DeleteDirty-check each folder, delete the branches, git worktree remove each, remove the container, keep each base repo's worktree list honestDestroy the sandbox
Failure modes to handleStale worktree registrations, "branch is already used by worktree at <missing-path>", partial rollback across N folders, the rm -rf guard resolving against the right project idClone failures on a cold start. That is the list
DiskCheap — worktrees share the base repo's object store, so N folders is not N clonesN full clones unless the snapshot is warm
-
- -

What this means for the build

-

- Read the table right to left and the useful conclusion falls out: almost every hard part - of this plan is device-only. Atomic rollback across N folders, the branch-collision - pass, the delete saga and its safety guard, resolving folders to existing local projects — none - of it exists in the cloud path. The device path is carrying the complexity, and it is the one we - are building first anyway. -

-

- So cloud should land as a genuinely small addition on a shared model, not a second - implementation. The thing to protect is that split: one branch, in one step - (step 4 below), and no "is cloud" flag threaded through resolution, branch naming, registration, - rollback or delete. If that flag starts spreading, the two paths have begun to diverge and the - shared model is no longer real. -

- -
-

- The one thing cloud makes harder, not easier. Worktrees make N folders nearly - free on disk; clones do not. A three-repo workspace in a sandbox is three full checkouts plus - three sets of dependencies, against a writable disk that is half the environment's memory. - That is why environment size, warm snapshots and a cap on folders per project are cloud - concerns — on a device they barely register. -

-
-
- -
-

Where we diverge from Codex

-

They mount folders. We check them out.

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 Codex multi-folder projectsThis plan
Primary folderGit root; drives chats, git ops, AGENTS.md discoverySame
Additional foldersExisting directories mounted as-is, .git/ read-onlyAn isolated checkout each, on the workspace's branch — worktree on a device, clone in a sandbox
Parallel workTwo threads edit the same secondary directory and trample each otherEvery workspace owns its checkouts; nothing is shared
Branch in a secondaryNone — whatever the folder happened to be onThe workspace's branch, so commits land somewhere meaningful
Shipping a secondary"Each repository must be committed independently", outside the appA real branch exists, so per-repo PRs become an increment, not a redesign
-
- -

- That last row is the point. Codex's secondaries have nowhere for a commit to go, so per-repo - pull requests would need a redesign before they could even start. Ours have a branch from day - one, which is what makes the Ship surface a later increment rather than a different product. -

-
- -
-

Data model

-

What the mapping is today, and the smallest change that makes it a list.

- -

How a project maps to a repo today

-

- There is no mapping table. The mapping is a column, and it lives in three - places that are mid-migration — which is the single most important thing to know before adding - anything. -

- -
- - - - - - - - - - - - - - - - - - - - - -
StoreThe mappingStatus
projects
host.db, per machine
repo_path — one repo checked out at one path, NOT NULL. repo_owner, repo_name, repo_url, remote_name are denormalised from the git remote at write time. workspaces.project_id points hereThe authority. This is what ws create --project resolves and what every git surface reaches through
projects + worktrees
local-db, desktop renderer
main_repo_path plus github_owner, with one worktrees row per checkout underneathThe v1 store. Being migrated away from, paced by the V1_AUTO_MIGRATION flag
v2_projects
cloud Postgres, org-level
repo_clone_url, plus github_repository_id into github_repositoriesBeing removed. v2_workspaces.project_id already dropped its FK "ahead of the v2_projects table removal", and the one remaining reader is described in the code as "the last consumer… it dies with the v1 importer"
-
- -

And there is no link column between them

-

- Nothing on a host project stores a cloud project id. When the two have to be associated, it - happens by matching the git remote URL at lookup time through - v2Project.findByGitHubRemote — and even that only runs on the v1 importer path. - Meanwhile v2_workspaces.project_id is now written with the host-side local - project id, which is exactly why its foreign key could be dropped. -

- -
-

- Which settles where the folder list goes. An earlier version of this page put - it in the cloud, hanging off v2_projects. That was wrong — you cannot add a table - to one that is being deleted. The project definition has already moved host-side, so the - folder list follows it: project_folders in host.db, beside the - table that already owns repo_path. -

-

- This makes the plan simpler, not harder. Multi-repo becomes entirely host-side - — no Postgres migration, no cloud schema change — which is consistent with - ws create --project already taking a local project id. -

-
- -

Host — the project's folder list

-

- A project keeps its own columns and gains rows. An existing project gets exactly one row - mirroring repo_path, so a single-repo project is a multi-repo project with N=1. -

-
project_folders (new, host.db)
-  project_id      → projects.id, cascade
-  position        0 is the primary
-  folder          the directory name inside the container
-  repo_path       the base repo on this machine, once resolved
-  base_branch
-  unique(project_id, position) · unique(project_id, folder)
-

- The existing projects.repo_path and its parsed remote columns keep mirroring - position 0 through the transition, so every reader that has not been taught about lists carries - on working. -

- -

Host — which checkouts a workspace has

-
workspace_repos (new, host.db)
-  workspace_id    → workspaces.id, cascade
-  position        0 is the primary
-  project_id      → the local project this checkout belongs to
-  folder, worktree_path, branch, base_branch
-  unique(workspace_id, position) · unique(workspace_id, project_id)
-
-workspaces (existing)
-  + root_path        nullable · the container. NULL = today's single-repo workspace
-  worktreePath    unchanged — still the primary checkout
- -
-

A prior-art note, not a shortcut

-

- local-db's worktrees table is already - { id, projectId, path, branch, baseBranch, gitStatus, … } — one row per checkout, - keyed by project, with no assumption that a workspace has only one. Worth reading as - precedent for the workspace_repos shape. -

-

- It is not something we can build on, though: that table belongs to the v1 - desktop store this repo is migrating away from. An earlier draft of this page claimed the - folder picker got its registry and its path validation from it for free. It does not. -

-

packages/local-db/src/schema/schema.ts:70

-
-
- -
-

Projects v2, behind a flag

-

A new creation path. The old one keeps working untouched.

- -

- The flag is a PostHog gate registered in FEATURE_FLAGS - (packages/shared/src/constants.ts:105) and read with - useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS), matching how - CLOUD_ACCESS and V1_AUTO_MIGRATION already work. -

-

- "v2" here means the second version of the project shape, and is unrelated to the - v1→v2 desktop migration V1_AUTO_MIGRATION paces. Worth picking different words - before this ships — the collision will cost someone an afternoon otherwise. -

- -

What the flag actually gates

-
    -
  • On: the project form shows a Source folders list — add a folder, remove one, mark one primary. Workspaces created in a project with 2+ folders get the multi-repo path.
  • -
  • Off: the form is exactly what it is today. One folder, no list, no picker.
  • -
- -

Backward compatibility, stated as rules

-
    -
  1. One folder is not a special case, it is the general case with N=1. A project with a single folder produces a workspace with root_path = NULL and one workspace_repos row whose worktree_path equals worktreePath. Byte-for-byte what exists today.
  2. -
  3. No folder moves on disk. Existing workspaces are never restructured into a container. Only workspaces created after the flag, in a project with 2+ folders, get one.
  4. -
  5. Old clients ignore the new fields. root_path and the repos array are additive; a pre-change desktop reads worktreePath and behaves as before, which is why the primary had to stay there.
  6. -
  7. The migration is a backfill, not a rewrite. One workspace_repos row per existing workspace, idempotent, derived from columns that already exist.
  8. -
- -
-

- The invariant to enforce with a test: root_path is never passed - to git, to a setup script, or to a worktree operation. It is a display and launch path only. - Kilo has an open issue from a maintainer asking for exactly this guard, because their - workspace root doubles as their git root and pointing it at a non-git folder breaks worktrees - and setup scripts. Our two fields keep that impossible — as long as nothing crosses them. -

-
-
- -
-

Creating the workspace

-

A loop, a shared branch name, and a rollback that leaves nothing behind.

- -
    -
  1. -

    Resolve the folder list

    -

    - Copy the project's folders onto the workspace at creation time. Editing the project later - changes future workspaces only; a live workspace never changes shape. -

    -
  2. -
  3. -

    Resolve each folder to a local project — device only

    -

    - Match by explicit link, then by remote URL, otherwise clone to - ~/.superset/repos/<owner>/<name> and create the local project. Progress - streams into the workspace's setup terminal so a cold first run is visible rather than a hang. -

    -

    - A sandbox skips this entirely — there are no pre-existing local projects to match against, - so the clone in step 4 is both the fetch and the working copy. -

    -
  4. -
  5. -

    Pick the branch name

    -

    - Use the same name in every repo where it is free, and bump only the repo where it is taken. - Names match in the ordinary case; nothing breaks when they don't. Cross-links live in PR - bodies, so nothing downstream depends on the names being identical. -

    -
  6. -
  7. -

    Create the container, then N checkouts

    -

    - mkdir the container, then materialise each folder, primary first. Base branch - comes from each folder's row, so homebrew-tap can fork from - master while the others fork from main. -

    -

    - This is the only step that differs by environment, and it should stay that way. - A device runs git worktree add -b against the local base repo. A sandbox runs - git clone then git checkout -b — or a shallow fetch, against a warm - snapshot. Everything before and after is identical, so confining the branch here is what - stops the two paths diverging later. -

    -
  8. -
  9. -

    Register and persist

    -

    - Insert the workspaces row with root_path set, N - workspace_repos rows, and N rows in the desktop's worktrees registry - so the Changes pane will accept each path. -

    -
  10. -
  11. -

    Roll back as a unit

    -

    - If any worktree fails, remove the ones already created, delete the container, and surface an - error naming the folder that failed. A half-built multi-repo workspace is worse than none. -

    -
  12. -
- -

Delete is the same loop, reversed — on a device

-

- List the folders, refuse if any is dirty and name it, apply "delete branch" to all of them, - git worktree remove each, then remove the container. The existing - rm -rf safety guard checks a path is inside the project's managed worktrees root — - it needs to take the primary's project id, since the secondaries' worktrees live under - the primary's tree and would otherwise fail the check and silently refuse to clean up. -

-

- In a sandbox none of this exists. Destroying the sandbox destroys every checkout at once: no - dirty gate, no per-folder removal, no container cleanup, no base-repo worktree lists left to - keep honest. The dirty check is still worth offering as a warning before destroy, but it is a - courtesy there rather than a correctness requirement. -

-
- -
-

Launching the agent

-

In the primary, not the container — and that decision pays for itself.

- -

- The agent's cwd is the primary worktree. The spawn procedure already takes an - optional cwd (terminal-agents.ts:545), so this is a value, not new - plumbing. Two things fall out, and they are the reason this is the right call: -

- -
-
-

The primary's instructions load eagerly

-
landmine avoided
-
    -
  • Claude Code loads CLAUDE.md from cwd and every directory above it at launch
  • -
  • Files in subdirectories load only when it reads inside them
  • -
  • cwd = primary means the primary's CLAUDE.md is in context from the first token
  • -
-
-
-

Auto memory stays where it belongs

-
landmine avoided
-
    -
  • The memory directory is derived from the git repository; outside a git repo it falls back to the project root
  • -
  • A container is not a git repo and its path is per-workspace — so launching there would give every workspace its own empty memory, discarded on delete
  • -
  • cwd = primary keeps one memory directory shared across all worktrees of that repo, as today
  • -
-
-
- -
-

- The cost, stated plainly: the other folders are now siblings of cwd, - not children, so the agent cannot reach them by default. Launch has to pass each secondary as - an additional directory, and Anthropic's docs are explicit that those directories' CLAUDE.md - files are not loaded unless - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 is set — so we set it. -

-

- This is a trade, not a free win. Launching at the container would have made the folders - children and skipped the flag entirely, at the price of both landmines above. Fragmented auto - memory is the worse outcome: it degrades agent quality silently and no test would catch it. - Worth knowing that additional-directory permission handling has rough edges in practice — - claude-code#36682 — so - this deserves a real end-to-end check, not an assumption. -

-
- -

What the workspace tells the agent

-

- The creation message says the workspace spans several folders rather than leaving the agent to - discover it. Alongside it, a manifest at <root>/.superset/workspace.json and - SUPERSET_WORKSPACE_REPOS in the environment list the folders, their repos and their - branches. One trap worth naming in that message: git status at the container fails, - because the container is not a repo. The agent has to be in a folder. -

-
- -
-

The folder picker

-

One control, re-roots the pane below it.

- -
- 📁  sample-test  ▾
-  
- 📁  evidence
- 📁  sample-test  ✓
-  
- ├─ test-same/
- ├─ tree/
- ├─ README.md M
- └─ tree.zip -
- -

- A dropdown at the top of the right-hand pane listing the workspace's folders with a check on the - active one, exactly as in Codex. Selecting a folder re-roots the file tree and the Changes list - below it. Dirty counts per folder belong on the rows so you can see where the work is without - opening each one. -

- -

The client side is small

-

- The file tree derives its root from a single expression — - const rootPath = workspaceQuery.data?.worktreePath ?? "" in the v2 - FilesTab. The picker replaces that one expression with the selected folder's path. -

-

v2-workspace/$workspaceId/.../FilesTab/FilesTab.tsx:92

- -

The server side is not

-

- The v2 workspace surface reads git through - workspaceTrpc.git.getStatus({ workspaceId }) — the host-service router, keyed by - workspace id. So the picker needs, for every workspace and not only remote ones: -

-
    -
  • An optional repo argument on the git procedures, resolved through the one 31-line resolveWorktreePath that all 24 call sites share. Omitting it keeps returning the primary, so nothing existing changes.
  • -
  • The status store re-keyed from workspaceId → baseBranch to include the folder. Three repos in one workspace usually share a base branch name, so today they would collide on one key and the last snapshot would win — showing one repo's diff under another's name.
  • -
  • The refresh limiter re-keyed the same way, or the repos serialise behind one active slot.
  • -
  • N file watchers per workspace instead of one, with refcounting, or edits outside the primary never mark the workspace dirty.
  • -
- -
-

- Correcting an earlier version of this page. It claimed the desktop's - changes router is path-based end to end and that the picker was therefore nearly - free for local workspaces. That router is path-based — but it belongs to the v1 - surface. The v2 workspace, which is the one being built on, goes through the host-service git - router keyed by workspace id. The re-keying work above is required for local workspaces too, - not deferred with cloud. -

-
- -

State

-

- The selected folder is per workspace, which is entity-keyed with unbounded cardinality — so per - the desktop's persisted-state policy it belongs in host-side SQLite, not localStorage. -

-
- -
-

Blast radius

-

Every surface that assumes a workspace is one repo.

- -

- The map below is the workspace window. Blue is an existing element that changes meaning; green - dashed is new; grey is untouched. The numbers key to the table underneath. -

- -
-
- Workspace header - 2 PR chips — one per folder, aggregate check status - 3 Branch & dirty indicator — whose branch, whose dirt? -
- -
- Sidebar - 1 Workspace row — repo-count badge, filter matches any member - Project groups — unchanged in v1 -
- -
- Main pane - 4 Terminal / agent -

- cwd is the primary. Panes, tabs and the editor are untouched — they already take an - absolute path. -

-
- -
- Right pane - 5 Folder picker — NEW - 6 Files — rooted at selection - 7 Changes — diff of selection - 8 Ship — one row per folder -
-
- -
- changes meaning - new - untouched -
- -

What changes, surface by surface

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 SurfaceTodayChange
1Sidebar workspace rowOne row, one repo implied by its projectRepo-count badge; filtering by any member repo matches. Grouping stays under the primary
2PR chips & check statusOne chip from workspaces.pull_request_idOne chip per folder, read from the join table; aggregate status is the worst of the members
3Header branch / dirtyThe workspace's one branch and its dirtMirrors the selected folder, or shows an aggregate. Open question — see below
4Terminal, agent, panesOpen at worktreePathcwd is the primary; secondaries passed as additional directories. Panes and the editor are untouched — they already take absolute paths
5Folder picker—New control; selection persists per workspace in host-side SQLite
6Files tabrootPath = workspace.worktreePathOne expression — root at the selected folder
7Changesgit.getStatus({ workspaceId }){ workspaceId, repo }. Pulls in the store re-key, the limiter re-key and N watchers
8Ship / Create PRcanCreatePr = workspace.projectId != null — one button, one PROne row per folder with commits ahead, each opening a PR against its own base
-
- -

Yes — one PR per folder, and the schema is already there

-
-

- workspace_pull_requests is already a many-to-many join, primary-keyed on - (workspace_id, pull_request_id), and its own comment says "a workspace that - opens a PR per branch accumulates one row each." Each pull_requests row - already carries project_id, repo_owner and repo_name, - so a PR already knows which repo it belongs to. -

-

- So per-folder PRs need no schema change at all. - workspaces.pull_request_id stays what it is — the single "currently linked" - pointer the legacy sidebar reads — and keeps pointing at the primary's PR. - What changes is the UI that reads the join instead of the pointer, and - createForWorkspace taking a folder so it resolves the right checkout and base. -

-
- -

Server-side, where the real work is

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
WhatChangeWhere
Path resolutionOptional repo argument; omitted keeps returning the primary, so nothing existing movesresolveWorktreePath — 31 lines, 24 call sites, 21 of them in git.ts
Status cacheKey on the folder as well. Without it, repos sharing a base branch name collide and one repo's diff appears under another'sgit-status-store.ts:31
Refresh limiterKey on the folder, or the repos serialise behind one active slotgit-status-refresh-limiter.ts:31
File watcherN watcher pairs per workspace with refcounting, or edits outside the primary never mark it dirtyevents/git-watcher.ts
CreationThe loop, shared branch name, atomic rollbackworkspaces.ts:607
DeletePer-folder dirty gate and removal; the rm -rf guard must resolve against the primary's project id or it refuses to clean up secondariesworkspace-cleanup.ts:508,588
PR creationFolder argument; base branch from that folder's rowpull-requests/procedures/create-for-workspace.ts:40
Project CRUDproject_folders read/write, reorder, set-primarytrpc/router/project/
-
- -

Surfaces that do not change

-

- Worth stating, because it is most of the app. Panes, tabs, the editor, the terminal, file - read/write, attachments and search all take an absolute path already and never - ask which repo it came from. Automations need nothing: they target a project, and the project - now carries the list. The cloud database is untouched in v1. -

- -

The decision this forces

-
-

- Is "the workspace" singular or plural in the header? Item 3 is the one place - the model leaks into a surface that has no obvious answer. The branch name and dirty indicator - are workspace-level today. With N folders they can either follow the picker — making the - header a view of the selected folder — or aggregate, showing a count and the worst status. -

-

- Following the picker is more consistent and much cheaper. Aggregating tells you more at a - glance but invents a summary the git model has no concept of. Worth deciding before the picker - is built, because it determines whether the selection is pane-local state or workspace-level - state. -

-
-
- -
-

Build order

-

Four steps, each shippable, each useful on its own.

- -
    -
  1. -

    The host primitive

    -

    - workspace_repos, root_path, the creation loop, atomic rollback, - delete-all, and the backfill. Driven by a repeatable --project on the CLI and - a repos array in ws get. No UI at all. Dogfoodable from a terminal on day one. -

    -
  2. -
  3. -

    Project v2 behind the flag

    -

    - project_folders, the Source folders list in the project form, and the - workspace composer summarising the folders it will check out. -

    -
  4. -
  5. -

    Agent context

    -

    - Primary as cwd, secondaries as additional directories, - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD, the manifest, the environment - variable, and the creation message. Verified end to end against a real agent, because the - permission behaviour here is the part most likely to surprise us. -

    -
  6. -
  7. -

    The folder picker

    -

    - Changes and Files re-rooted by selection, with per-folder dirty counts. The client change is - one expression per pane; the real work is server-side and applies to every workspace — an - optional repo argument through resolveWorktreePath, the status - store and refresh limiter re-keyed to include the folder, and N file watchers instead of - one. This is the largest of the four steps. -

    -
  8. -
-
- -
-

Not in v1

-
-
-

Deferred

-
wanted, later
-
    -
  • Per-repo Ship and one PR per repo — the branch exists, so this is an increment
  • -
  • Cross-linked PR bodies and workspace-name titles
  • -
  • Cloud sandboxes: they keep cloning one repo for now. The shape is already designed for them (see The shape) — it is the clone loop and the seed step that are unbuilt, not the model
  • -
  • Sidebar grouping by project; a multi-repo workspace files under its primary
  • -
  • Per-repo setup scripts
  • -
-
-
-

Deleted, not deferred

-
the picker over local projects removes these
-
    -
  • A GitHub App "Add repositories" dialog with search, avatars and access states
  • -
  • Per-repository installation tokens — the folders are already cloned on the machine
  • -
  • Merged "all repositories" diff. One folder at a time, by selection
  • -
  • Per-folder branch names inside one workspace
  • -
-
-
-
- -
-

Open questions

-
    -
  1. Cap on folders per project. Eight is the PRD's number, chosen for sandbox disk and creation time. Nothing enforces it structurally and raising it is one line — but a cap that exists is easier to raise than one that never existed.
  2. -
  3. Does the picker belong in the header too? The right pane is the obvious home. Whether the workspace header should mirror the selection, or stay workspace-level, decides whether "which folder am I in" is a pane concept or a workspace concept.
  4. -
  5. What happens when a folder's repo is removed from the project? Existing workspaces keep it, per the PRD. Worth confirming that reads as intended rather than as a bug when the folder is still on disk.
  6. -
  7. Additional-directory permissions. Whether a secondary worktree is writable by the agent without per-call approval is the one behaviour here that has burned another vendor. This is a thing to verify early, not late.
  8. -
-
- -
- Companion to Multi-repo workspaces: the competitive scan, which covers what ten - other tools do here and why the worktree-per-folder difference is where the value is. The PRD - (docs/prd/multi-repo-projects.md) remains the source of truth for requirement ids; - this page is the device-only v1 cut of it. Pin a comment on any element to push back. -
- -
- - From e2c6729504913757654a7f7ec99cf37a713fd20d Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 21 Sep 2026 07:40:35 +0530 Subject: [PATCH 03/14] fix(host-service): address the workspace container, not just the primary checkout A multi-repo workspace opens at a container whose secondary checkouts are siblings of the primary, so anything scoped to `worktreePath` rejected them. Saving any file outside the primary failed with "Path is outside workspace root". Split the two meanings that were sharing one accessor: `resolveWorkspaceRoot` returns the container for the filesystem sandbox, `resolveWorkspacePrimaryWorktree` returns the primary for anything reaching for git. Pointing the single accessor at the container fixed saving but broke git watching, because `watchPath` accepts only the exact service root. Also: attribute agent usage by container so work in a secondary is not misattributed, give `listGitIgnoredDirs` a ceiling so it cannot walk out of a container into an enclosing repo, and refuse `project.remove` while another project's live workspace still checks that repo out. Claude-Session: https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon --- packages/host-service/src/events/event-bus.ts | 2 +- .../src/events/git-watcher-failure.test.ts | 2 +- .../events/git-watcher.adversarial3.test.ts | 2 +- .../host-service/src/events/git-watcher.ts | 2 +- .../src/runtime/filesystem/filesystem.ts | 18 +++++++++++ .../src/runtime/git/ignored-dirs.ts | 7 +++- .../src/trpc/router/filesystem/filesystem.ts | 7 ++-- .../git-status-store.adversarial3.test.ts | 2 +- .../src/trpc/router/ports/ports.ts | 4 ++- .../src/trpc/router/project/project.ts | 32 +++++++++++++++++-- .../src/trpc/router/usage/usage.ts | 3 +- 11 files changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/host-service/src/events/event-bus.ts b/packages/host-service/src/events/event-bus.ts index d69f77411b1..6cea6286b11 100644 --- a/packages/host-service/src/events/event-bus.ts +++ b/packages/host-service/src/events/event-bus.ts @@ -383,7 +383,7 @@ export class EventBus { private getPortLabel(port: DetectedPort): string | null { const labels = getLabelsForWorkspace((workspaceId) => { try { - return this.filesystem.resolveWorkspaceRoot(workspaceId); + return this.filesystem.resolveWorkspacePrimaryWorktree(workspaceId); } catch { return null; } diff --git a/packages/host-service/src/events/git-watcher-failure.test.ts b/packages/host-service/src/events/git-watcher-failure.test.ts index 77e13f395a5..9efe2958f5a 100644 --- a/packages/host-service/src/events/git-watcher-failure.test.ts +++ b/packages/host-service/src/events/git-watcher-failure.test.ts @@ -60,7 +60,7 @@ for (const failure of ["reject", "end", "subscribe", "git"] as const) { let failSubscribe = failure === "subscribe"; let backingOff = false; const filesystem = { - getServiceForWorkspace: () => ({ + getServiceForPrimaryWorktree: () => ({ watchPath: () => { if (failSubscribe) throw new Error("subscription failed"); const stream = new Stream(); diff --git a/packages/host-service/src/events/git-watcher.adversarial3.test.ts b/packages/host-service/src/events/git-watcher.adversarial3.test.ts index 23c71eea6ec..c055659e541 100644 --- a/packages/host-service/src/events/git-watcher.adversarial3.test.ts +++ b/packages/host-service/src/events/git-watcher.adversarial3.test.ts @@ -56,7 +56,7 @@ const WS = "ws-overflow"; function createWatcher(stream: FakeStream): GitWatcher { const filesystem = { - getServiceForWorkspace: () => ({ + getServiceForPrimaryWorktree: () => ({ watchPath: () => stream, }), }; diff --git a/packages/host-service/src/events/git-watcher.ts b/packages/host-service/src/events/git-watcher.ts index bead0a8781e..bee8c09319c 100644 --- a/packages/host-service/src/events/git-watcher.ts +++ b/packages/host-service/src/events/git-watcher.ts @@ -791,7 +791,7 @@ export class GitWatcher { let iterator: AsyncIterator<{ events: FsWatchEvent[] }> | null = null; try { - const service = this.filesystem.getServiceForWorkspace(workspaceId); + const service = this.filesystem.getServiceForPrimaryWorktree(workspaceId); const stream = service.watchPath({ absolutePath: worktreePath, }); diff --git a/packages/host-service/src/runtime/filesystem/filesystem.ts b/packages/host-service/src/runtime/filesystem/filesystem.ts index 530c2f70d32..73545098b1d 100644 --- a/packages/host-service/src/runtime/filesystem/filesystem.ts +++ b/packages/host-service/src/runtime/filesystem/filesystem.ts @@ -49,9 +49,27 @@ export class WorkspaceFilesystemManager { throw new WorkspaceNotFoundError(`Workspace not found: ${workspaceId}`); } + return workspace.rootPath ?? workspace.worktreePath; + } + + resolveWorkspacePrimaryWorktree(workspaceId: string): string { + const workspace = this.db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + + if (!workspace) { + throw new WorkspaceNotFoundError(`Workspace not found: ${workspaceId}`); + } + return workspace.worktreePath; } + getServiceForPrimaryWorktree(workspaceId: string): FsHostService { + return this.getServiceForRootPath( + this.resolveWorkspacePrimaryWorktree(workspaceId), + ); + } + resolveProjectRoot(projectId: string): string { const project = this.db.query.projects .findFirst({ where: eq(projects.id, projectId) }) diff --git a/packages/host-service/src/runtime/git/ignored-dirs.ts b/packages/host-service/src/runtime/git/ignored-dirs.ts index e159462fe3a..cd2ff97b270 100644 --- a/packages/host-service/src/runtime/git/ignored-dirs.ts +++ b/packages/host-service/src/runtime/git/ignored-dirs.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { dirname } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); @@ -39,7 +40,11 @@ export async function listGitIgnoredDirs(rootPath: string): Promise { "--directory", "-z", ], - { timeout: TIMEOUT_MS, maxBuffer: MAX_BUFFER_BYTES }, + { + timeout: TIMEOUT_MS, + maxBuffer: MAX_BUFFER_BYTES, + env: { ...process.env, GIT_CEILING_DIRECTORIES: dirname(rootPath) }, + }, ); const dirs: string[] = []; for (const entry of stdout.split("\0")) { diff --git a/packages/host-service/src/trpc/router/filesystem/filesystem.ts b/packages/host-service/src/trpc/router/filesystem/filesystem.ts index b7b09f3143d..aeae5aab924 100644 --- a/packages/host-service/src/trpc/router/filesystem/filesystem.ts +++ b/packages/host-service/src/trpc/router/filesystem/filesystem.ts @@ -301,9 +301,10 @@ export const filesystemRouter = router({ resolvedPath: string; isDirectory: boolean; } | null> => { - const resolvedRoot = ctx.runtime.filesystem.resolveWorkspaceRoot( - input.workspaceId, - ); + const resolvedRoot = + ctx.runtime.filesystem.resolveWorkspacePrimaryWorktree( + input.workspaceId, + ); let targetPath: string; if (input.path.startsWith("~")) { diff --git a/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.adversarial3.test.ts b/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.adversarial3.test.ts index 98a868c6065..cd0c6916a3a 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.adversarial3.test.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-status-store/git-status-store.adversarial3.test.ts @@ -131,7 +131,7 @@ describe("round 3: the real worktree watcher feeding the store", () => { }), }; const filesystem = { - getServiceForWorkspace: () => service, + getServiceForPrimaryWorktree: () => service, refreshWatcherIgnores: async () => false, isWatchAttachBackingOff: () => false, }; diff --git a/packages/host-service/src/trpc/router/ports/ports.ts b/packages/host-service/src/trpc/router/ports/ports.ts index cff912cc1a4..41f5bca4486 100644 --- a/packages/host-service/src/trpc/router/ports/ports.ts +++ b/packages/host-service/src/trpc/router/ports/ports.ts @@ -23,7 +23,9 @@ export const portsRouter = router({ const requestedWorkspaceIds = new Set(input.workspaceIds); const resolve = (workspaceId: string): string | null => { try { - return ctx.runtime.filesystem.resolveWorkspaceRoot(workspaceId); + return ctx.runtime.filesystem.resolveWorkspacePrimaryWorktree( + workspaceId, + ); } catch { // Workspace deleted or unknown — no labels for this row. return null; diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 9cc0d93dc02..6dea41f7e11 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -6,9 +6,14 @@ import { } from "@superset/shared/github-remote"; import { BRANCH_PREFIX_MODES } from "@superset/shared/workspace-launch"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull, ne } from "drizzle-orm"; import { z } from "zod"; -import { projects, tagFolderSettings, workspaces } from "../../../db/schema"; +import { + projects, + tagFolderSettings, + workspaceRepos, + workspaces, +} from "../../../db/schema"; import type { TagSettingSnapshot } from "../../../events/types"; import { emitProjectChanged, @@ -851,6 +856,29 @@ export const projectRouter = router({ .sync(); if (!localProject) return { success: true, repoPath: null }; + const memberOf = ctx.db + .select({ id: workspaces.id, name: workspaces.name }) + .from(workspaceRepos) + .innerJoin(workspaces, eq(workspaceRepos.workspaceId, workspaces.id)) + .where( + and( + eq(workspaceRepos.projectId, input.projectId), + ne(workspaces.projectId, input.projectId), + isNull(workspaces.archivedAt), + ), + ) + .all(); + if (memberOf.length > 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `This repository is checked out by ${memberOf.length} workspace(s) of another project: ${memberOf + .map((ws) => ws.name || ws.id) + .join( + ", ", + )}. Delete those workspaces first, or remove this folder from their project.`, + }); + } + // The project-row delete below cascades tombstones away — removing a // project intentionally drops its workspace history. Sweep worktrees // for live rows AND stranded tombstones (crash-interrupted deletes diff --git a/packages/host-service/src/trpc/router/usage/usage.ts b/packages/host-service/src/trpc/router/usage/usage.ts index 7a7771f803c..8c10cd1aec1 100644 --- a/packages/host-service/src/trpc/router/usage/usage.ts +++ b/packages/host-service/src/trpc/router/usage/usage.ts @@ -310,6 +310,7 @@ export const usageRouter = router({ const workspaceRows = ctx.db .select({ worktreePath: workspaces.worktreePath, + rootPath: workspaces.rootPath, name: workspaces.name, projectId: workspaces.projectId, }) @@ -331,7 +332,7 @@ export const usageRouter = router({ ); const cwdLabels = [ ...workspaceRows.map((row) => ({ - prefix: row.worktreePath, + prefix: row.rootPath ?? row.worktreePath, label: row.name || basename(row.worktreePath), kind: "workspace" as const, group: row.projectId From fc8c8159d9793b173b2ba31f0324528c45c837c5 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 21 Sep 2026 07:40:51 +0530 Subject: [PATCH 04/14] feat(host-service): add a project container above repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `projects.repo_path` is NOT NULL, so a project is required to be a repository. A multi-repo project was therefore its primary repo renamed, and one repo could anchor exactly one project. Codex models a project as its own identity with a name and an ordered folder list, and lets the same folder back any number of projects. Add that container rather than changing what a project is: `project_groups` holds the name, `project_group_members` holds the ordered membership, and `projects` is untouched — all 173 readers of `repo_path` keep their invariant. `ON DELETE restrict` on the member's project reference is the load-bearing part: the database refuses to delete a repository a group still references, so the cascade that could strand a live workspace's checkout is unreachable rather than guarded. Ships dark. Nothing reads these tables yet. Claude-Session: https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon --- .../drizzle/0037_new_stingray.sql | 26 + .../drizzle/meta/0037_snapshot.json | 1586 +++++++++++++++++ .../host-service/drizzle/meta/_journal.json | 7 + packages/host-service/src/app.ts | 11 + packages/host-service/src/db/schema.ts | 50 + .../src/projects/project-groups.ts | 118 ++ .../runtime/project-group-backfill.test.ts | 208 +++ .../src/runtime/project-group-backfill.ts | 143 ++ .../src/trpc/router/project-groups/index.ts | 1 + .../project-groups/project-groups.test.ts | 233 +++ .../router/project-groups/project-groups.ts | 271 +++ .../host-service/src/trpc/router/router.ts | 2 + 12 files changed, 2656 insertions(+) create mode 100644 packages/host-service/drizzle/0037_new_stingray.sql create mode 100644 packages/host-service/drizzle/meta/0037_snapshot.json create mode 100644 packages/host-service/src/projects/project-groups.ts create mode 100644 packages/host-service/src/runtime/project-group-backfill.test.ts create mode 100644 packages/host-service/src/runtime/project-group-backfill.ts create mode 100644 packages/host-service/src/trpc/router/project-groups/index.ts create mode 100644 packages/host-service/src/trpc/router/project-groups/project-groups.test.ts create mode 100644 packages/host-service/src/trpc/router/project-groups/project-groups.ts diff --git a/packages/host-service/drizzle/0037_new_stingray.sql b/packages/host-service/drizzle/0037_new_stingray.sql new file mode 100644 index 00000000000..89b58439791 --- /dev/null +++ b/packages/host-service/drizzle/0037_new_stingray.sql @@ -0,0 +1,26 @@ +CREATE TABLE `project_group_members` ( + `id` text PRIMARY KEY NOT NULL, + `group_id` text NOT NULL, + `project_id` text NOT NULL, + `position` integer NOT NULL, + `folder` text NOT NULL, + `base_branch` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`group_id`) REFERENCES `project_groups`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE restrict +); +--> statement-breakpoint +CREATE UNIQUE INDEX `project_group_members_group_position_unique` ON `project_group_members` (`group_id`,`position`);--> statement-breakpoint +CREATE UNIQUE INDEX `project_group_members_group_folder_unique` ON `project_group_members` (`group_id`,`folder`);--> statement-breakpoint +CREATE UNIQUE INDEX `project_group_members_group_project_unique` ON `project_group_members` (`group_id`,`project_id`);--> statement-breakpoint +CREATE INDEX `project_group_members_project_id_idx` ON `project_group_members` (`project_id`);--> statement-breakpoint +CREATE TABLE `project_groups` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `icon` text, + `color` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE `workspaces` ADD `group_id` text REFERENCES project_groups(id); \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0037_snapshot.json b/packages/host-service/drizzle/meta/0037_snapshot.json new file mode 100644 index 00000000000..0e9e7c2a6e6 --- /dev/null +++ b/packages/host-service/drizzle/meta/0037_snapshot.json @@ -0,0 +1,1586 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "755772b0-2982-40ee-81b1-3eb0b7778ab0", + "prevId": "b631ce9a-f184-4a79-a0b4-89343a9ed1c5", + "tables": { + "host_agent_configs": { + "name": "host_agent_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preset_id": { + "name": "preset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_id": { + "name": "icon_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "args_json": { + "name": "args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "prompt_transport": { + "name": "prompt_transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_args_json": { + "name": "prompt_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "resume_args_json": { + "name": "resume_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "fork_args_json": { + "name": "fork_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "env_json": { + "name": "env_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_agent_configs_display_order_idx": { + "name": "host_agent_configs_display_order_idx", + "columns": [ + "display_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_settings": { + "name": "host_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_claude_config_dir": { + "name": "default_claude_config_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_codex_home": { + "name": "default_codex_home", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_folders": { + "name": "project_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_folders_project_position_unique": { + "name": "project_folders_project_position_unique", + "columns": [ + "project_id", + "position" + ], + "isUnique": true + }, + "project_folders_project_folder_unique": { + "name": "project_folders_project_folder_unique", + "columns": [ + "project_id", + "folder" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_folders_project_id_projects_id_fk": { + "name": "project_folders_project_id_projects_id_fk", + "tableFrom": "project_folders", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_group_members": { + "name": "project_group_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_group_members_group_position_unique": { + "name": "project_group_members_group_position_unique", + "columns": [ + "group_id", + "position" + ], + "isUnique": true + }, + "project_group_members_group_folder_unique": { + "name": "project_group_members_group_folder_unique", + "columns": [ + "group_id", + "folder" + ], + "isUnique": true + }, + "project_group_members_group_project_unique": { + "name": "project_group_members_group_project_unique", + "columns": [ + "group_id", + "project_id" + ], + "isUnique": true + }, + "project_group_members_project_id_idx": { + "name": "project_group_members_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "project_group_members_group_id_project_groups_id_fk": { + "name": "project_group_members_group_id_project_groups_id_fk", + "tableFrom": "project_group_members", + "tableTo": "project_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_group_members_project_id_projects_id_fk": { + "name": "project_group_members_project_id_projects_id_fk", + "tableFrom": "project_group_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_groups": { + "name": "project_groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sparse_checkout_paths": { + "name": "sparse_checkout_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "naming_instructions": { + "name": "naming_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "merged_at": { + "name": "merged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag_folder_settings": { + "name": "tag_folder_settings", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tag_folder_settings_scope_tag_created_by_user_id_pk": { + "columns": [ + "scope", + "tag", + "created_by_user_id" + ], + "name": "tag_folder_settings_scope_tag_created_by_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_agent_bindings": { + "name": "terminal_agent_bindings", + "columns": { + "terminal_id": { + "name": "terminal_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_type": { + "name": "last_event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resumed_into_terminal_id": { + "name": "resumed_into_terminal_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_agent_bindings_workspace_id_idx": { + "name": "terminal_agent_bindings_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk": { + "name": "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk", + "tableFrom": "terminal_agent_bindings", + "tableTo": "terminal_sessions", + "columnsFrom": [ + "terminal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_workspace_id": { + "name": "origin_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispose_requested_at": { + "name": "dispose_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", + "columns": [ + "origin_workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "origin_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_pull_requests": { + "name": "workspace_pull_requests", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_pull_requests_workspace_idx": { + "name": "workspace_pull_requests_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_pull_requests_workspace_id_workspaces_id_fk": { + "name": "workspace_pull_requests_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_pull_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_pull_requests_pull_request_id_pull_requests_id_fk": { + "name": "workspace_pull_requests_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspace_pull_requests", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_pull_requests_workspace_id_pull_request_id_pk": { + "columns": [ + "workspace_id", + "pull_request_id" + ], + "name": "workspace_pull_requests_workspace_id_pull_request_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_repos": { + "name": "workspace_repos", + "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 + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_repos_workspace_position_unique": { + "name": "workspace_repos_workspace_position_unique", + "columns": [ + "workspace_id", + "position" + ], + "isUnique": true + }, + "workspace_repos_workspace_project_unique": { + "name": "workspace_repos_workspace_project_unique", + "columns": [ + "workspace_id", + "project_id" + ], + "isUnique": true + }, + "workspace_repos_project_id_idx": { + "name": "workspace_repos_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_repos_workspace_id_workspaces_id_fk": { + "name": "workspace_repos_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_repos", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_repos_project_id_projects_id_fk": { + "name": "workspace_repos_project_id_projects_id_fk", + "tableFrom": "workspace_repos", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_tags": { + "name": "workspace_tags", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_tags_tag_idx": { + "name": "workspace_tags_tag_idx", + "columns": [ + "tag" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_tags_workspace_id_workspaces_id_fk": { + "name": "workspace_tags_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_tags", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_tags_workspace_id_tag_created_by_user_id_pk": { + "columns": [ + "workspace_id", + "tag", + "created_by_user_id" + ], + "name": "workspace_tags_workspace_id_tag_created_by_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "root_path": { + "name": "root_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_owner": { + "name": "upstream_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_repo": { + "name": "upstream_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_branch": { + "name": "upstream_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suppressed_pull_request_id": { + "name": "suppressed_pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'worktree'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archive_reason": { + "name": "archive_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_archived_at_idx": { + "name": "workspaces_archived_at_idx", + "columns": [ + "archived_at" + ], + "isUnique": false + }, + "workspaces_upstream_ref_idx": { + "name": "workspaces_upstream_ref_idx", + "columns": [ + "upstream_owner", + "upstream_repo", + "upstream_branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_group_id_project_groups_id_fk": { + "name": "workspaces_group_id_project_groups_id_fk", + "tableFrom": "workspaces", + "tableTo": "project_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_suppressed_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_suppressed_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "suppressed_pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index c025957b265..787a77eac84 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1789746564470, "tag": "0036_sweet_king_bedlam", "breakpoints": true + }, + { + "idx": 37, + "version": "6", + "when": 1789943058789, + "tag": "0037_new_stingray", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 96022744114..f14e9f0afec 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -28,6 +28,7 @@ import type { GitCredentialProvider } from "./runtime/git"; import { createGitEnvResolver, createGitFactory } from "./runtime/git"; import { runMultiRepoBackfill } from "./runtime/multi-repo-backfill"; import { runProjectBackfill } from "./runtime/project-backfill"; +import { runProjectGroupBackfill } from "./runtime/project-group-backfill"; import { PullRequestRuntimeManager } from "./runtime/pull-requests"; import { launchSandboxAgentOnce, @@ -294,6 +295,16 @@ export function createApp(options: CreateAppOptions): CreateAppResult { } catch (err) { console.warn("[host-service] multi-repo backfill failed:", err); } + try { + const grouped = runProjectGroupBackfill({ db }); + if (grouped.groups > 0) { + console.log( + `[project-group-backfill] backfilled ${grouped.groups} group(s) and ${grouped.members} member(s)`, + ); + } + } catch (err) { + console.warn("[host-service] project group backfill failed:", err); + } // Finish any delete the previous process crashed out of (archived row // whose worktree still exists). await runArchivedWorkspaceReconcile({ diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index 76adf5b4a21..9aa0cc7b296 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -157,6 +157,53 @@ export const projectFolders = sqliteTable( ], ); +export const projectGroups = sqliteTable("project_groups", { + id: text().primaryKey(), + name: text().notNull(), + icon: text(), + color: text(), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + updatedAt: integer("updated_at") + .notNull() + .$defaultFn(() => Date.now()), +}); + +export const projectGroupMembers = sqliteTable( + "project_group_members", + { + id: text().primaryKey(), + groupId: text("group_id") + .notNull() + .references(() => projectGroups.id, { onDelete: "cascade" }), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "restrict" }), + position: integer().notNull(), + folder: text().notNull(), + baseBranch: text("base_branch"), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + }, + (table) => [ + uniqueIndex("project_group_members_group_position_unique").on( + table.groupId, + table.position, + ), + uniqueIndex("project_group_members_group_folder_unique").on( + table.groupId, + table.folder, + ), + uniqueIndex("project_group_members_group_project_unique").on( + table.groupId, + table.projectId, + ), + index("project_group_members_project_id_idx").on(table.projectId), + ], +); + /** * Single-row host-wide settings (always `id = 1`). The host-service has no * generic settings store yet; this row holds host-wide knobs (worktree base @@ -266,6 +313,9 @@ export const workspaces = sqliteTable( projectId: text("project_id").references(() => projects.id, { onDelete: "cascade", }), + groupId: text("group_id").references(() => projectGroups.id, { + onDelete: "set null", + }), worktreePath: text("worktree_path").notNull(), // The container directory holding one worktree per project folder, NULL // for a single-repo workspace. Not a git checkout: never hand it to diff --git a/packages/host-service/src/projects/project-groups.ts b/packages/host-service/src/projects/project-groups.ts new file mode 100644 index 00000000000..ee989cd43d5 --- /dev/null +++ b/packages/host-service/src/projects/project-groups.ts @@ -0,0 +1,118 @@ +import { eq } from "drizzle-orm"; +import type { HostDb } from "../db"; +import { projectGroupMembers, projectGroups } from "../db/schema"; + +export interface ProjectGroupMember { + id: string; + groupId: string; + projectId: string; + position: number; + folder: string; + baseBranch: string | null; +} + +export interface ProjectGroup { + id: string; + name: string; + icon: string | null; + color: string | null; + createdAt: number; + updatedAt: number; + members: ProjectGroupMember[]; +} + +function byPosition(a: ProjectGroupMember, b: ProjectGroupMember): number { + return a.position - b.position; +} + +export function listProjectGroupMembers( + db: HostDb, + groupId: string, +): ProjectGroupMember[] { + return db + .select() + .from(projectGroupMembers) + .where(eq(projectGroupMembers.groupId, groupId)) + .all() + .map((row) => ({ + id: row.id, + groupId: row.groupId, + projectId: row.projectId, + position: row.position, + folder: row.folder, + baseBranch: row.baseBranch, + })) + .sort(byPosition); +} + +export function getProjectGroup( + db: HostDb, + groupId: string, +): ProjectGroup | null { + const row = db + .select() + .from(projectGroups) + .where(eq(projectGroups.id, groupId)) + .get(); + if (!row) return null; + return { + id: row.id, + name: row.name, + icon: row.icon, + color: row.color, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + members: listProjectGroupMembers(db, groupId), + }; +} + +export function listProjectGroups(db: HostDb): ProjectGroup[] { + const membersByGroup = new Map(); + for (const row of db.select().from(projectGroupMembers).all()) { + const members = membersByGroup.get(row.groupId) ?? []; + members.push({ + id: row.id, + groupId: row.groupId, + projectId: row.projectId, + position: row.position, + folder: row.folder, + baseBranch: row.baseBranch, + }); + membersByGroup.set(row.groupId, members); + } + return db + .select() + .from(projectGroups) + .all() + .map((row) => ({ + id: row.id, + name: row.name, + icon: row.icon, + color: row.color, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + members: (membersByGroup.get(row.id) ?? []).sort(byPosition), + })) + .sort((a, b) => a.createdAt - b.createdAt); +} + +export function reassignMemberPositions( + db: HostDb, + orderedMemberIds: string[], +): void { + const positionParkedOutsideTheUniqueRange = (index: number) => -(index + 1); + db.transaction((tx) => { + orderedMemberIds.forEach((id, index) => { + tx.update(projectGroupMembers) + .set({ position: positionParkedOutsideTheUniqueRange(index) }) + .where(eq(projectGroupMembers.id, id)) + .run(); + }); + orderedMemberIds.forEach((id, index) => { + tx.update(projectGroupMembers) + .set({ position: index }) + .where(eq(projectGroupMembers.id, id)) + .run(); + }); + }); +} diff --git a/packages/host-service/src/runtime/project-group-backfill.test.ts b/packages/host-service/src/runtime/project-group-backfill.test.ts new file mode 100644 index 00000000000..d303533f6b0 --- /dev/null +++ b/packages/host-service/src/runtime/project-group-backfill.test.ts @@ -0,0 +1,208 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, it } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { HostDb } from "../db"; +import * as schema from "../db/schema"; +import { + projectFolders, + projectGroupMembers, + projectGroups, + projects, +} from "../db/schema"; +import { runProjectGroupBackfill } from "./project-group-backfill"; + +const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../drizzle"); + +function createDb(): HostDb { + const sqlite = new Database(":memory:"); + sqlite.run("PRAGMA foreign_keys = OFF"); + const rawDb = drizzle(sqlite, { schema }); + migrate(rawDb, { migrationsFolder: MIGRATIONS_FOLDER }); + sqlite.run("PRAGMA foreign_keys = ON"); + return rawDb as unknown as HostDb; +} + +function insertProject( + db: HostDb, + values: { + repoPath: string; + name?: string; + icon?: string | null; + color?: string | null; + }, +): string { + const id = randomUUID(); + db.insert(projects) + .values({ + id, + repoPath: values.repoPath, + name: values.name ?? "", + icon: values.icon ?? null, + color: values.color ?? null, + updatedAt: 1, + }) + .run(); + return id; +} + +function countRows(db: HostDb) { + return { + groups: db.select().from(projectGroups).all().length, + members: db.select().from(projectGroupMembers).all().length, + projects: db.select().from(projects).all().length, + }; +} + +describe("project group backfill", () => { + it("gives a project with no folder rows a group holding it alone", () => { + const db = createDb(); + const projectId = insertProject(db, { + repoPath: "/home/me/code/api", + name: "API", + icon: "data:image/png;base64,aa", + color: "#ff0000", + }); + + expect(runProjectGroupBackfill({ db })).toEqual({ groups: 1, members: 1 }); + + const group = db.select().from(projectGroups).all()[0]; + expect(group?.name).toBe("API"); + expect(group?.icon).toBe("data:image/png;base64,aa"); + expect(group?.color).toBe("#ff0000"); + expect(db.select().from(projectGroupMembers).all()).toMatchObject([ + { groupId: group?.id, projectId, position: 0, folder: "api" }, + ]); + }); + + it("names the group after the repo directory when the project is unnamed", () => { + const db = createDb(); + insertProject(db, { repoPath: "/home/me/code/web" }); + + runProjectGroupBackfill({ db }); + + expect(db.select().from(projectGroups).all()[0]?.name).toBe("web"); + }); + + it("builds members from the project's folders, resolving each to a project", () => { + const db = createDb(); + const primary = insertProject(db, { + repoPath: "/home/me/code/api", + name: "API", + }); + const secondary = insertProject(db, { + repoPath: "/home/me/code/web", + name: "Web", + }); + const now = Date.now(); + db.insert(projectFolders) + .values([ + { + id: randomUUID(), + projectId: primary, + position: 0, + folder: "api", + repoPath: "/home/me/code/api", + baseBranch: "main", + createdAt: now, + updatedAt: now, + }, + { + id: randomUUID(), + projectId: primary, + position: 1, + folder: "web", + repoPath: "/home/me/code/web", + baseBranch: "develop", + createdAt: now, + updatedAt: now, + }, + ]) + .run(); + + runProjectGroupBackfill({ db }); + + const group = db + .select() + .from(projectGroups) + .all() + .find((row) => row.name === "API"); + const members = db + .select() + .from(projectGroupMembers) + .all() + .filter((row) => row.groupId === group?.id) + .sort((a, b) => a.position - b.position); + expect(members).toMatchObject([ + { projectId: primary, position: 0, folder: "api", baseBranch: "main" }, + { + projectId: secondary, + position: 1, + folder: "web", + baseBranch: "develop", + }, + ]); + }); + + it("skips a folder whose checkout is not a project on this host", () => { + const db = createDb(); + const primary = insertProject(db, { + repoPath: "/home/me/code/api", + name: "API", + }); + const now = Date.now(); + db.insert(projectFolders) + .values([ + { + id: randomUUID(), + projectId: primary, + position: 0, + folder: "api", + repoPath: "/home/me/code/api", + createdAt: now, + updatedAt: now, + }, + { + id: randomUUID(), + projectId: primary, + position: 1, + folder: "unknown", + repoUrl: "https://github.com/acme/unknown", + createdAt: now, + updatedAt: now, + }, + ]) + .run(); + + expect(runProjectGroupBackfill({ db })).toEqual({ groups: 1, members: 1 }); + expect(db.select().from(projectGroupMembers).all()).toMatchObject([ + { projectId: primary, position: 0, folder: "api" }, + ]); + }); + + it("is idempotent: a second run changes nothing", () => { + const db = createDb(); + insertProject(db, { repoPath: "/home/me/code/api", name: "API" }); + insertProject(db, { repoPath: "/home/me/code/web", name: "Web" }); + + const first = runProjectGroupBackfill({ db }); + const afterFirst = countRows(db); + const second = runProjectGroupBackfill({ db }); + + expect(first).toEqual({ groups: 2, members: 2 }); + expect(second).toEqual({ groups: 0, members: 0 }); + expect(countRows(db)).toEqual(afterFirst); + }); + + it("groups only the projects that have no membership yet", () => { + const db = createDb(); + insertProject(db, { repoPath: "/home/me/code/api", name: "API" }); + runProjectGroupBackfill({ db }); + + insertProject(db, { repoPath: "/home/me/code/web", name: "Web" }); + expect(runProjectGroupBackfill({ db })).toEqual({ groups: 1, members: 1 }); + expect(countRows(db)).toEqual({ groups: 2, members: 2, projects: 2 }); + }); +}); diff --git a/packages/host-service/src/runtime/project-group-backfill.ts b/packages/host-service/src/runtime/project-group-backfill.ts new file mode 100644 index 00000000000..575ca58dd20 --- /dev/null +++ b/packages/host-service/src/runtime/project-group-backfill.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import { basename } from "node:path"; +import { eq, notExists, sql } from "drizzle-orm"; +import type { HostDb } from "../db"; +import { + projectFolders, + projectGroupMembers, + projectGroups, + projects, +} from "../db/schema"; +import { defaultFolderNameForRepo } from "../projects/project-folders"; + +export interface ProjectGroupBackfillContext { + db: HostDb; +} + +export interface ProjectGroupBackfillResult { + groups: number; + members: number; +} + +interface MemberValues { + id: string; + groupId: string; + projectId: string; + position: number; + folder: string; + baseBranch: string | null; + createdAt: number; +} + +export function runProjectGroupBackfill( + ctx: ProjectGroupBackfillContext, +): ProjectGroupBackfillResult { + const { db } = ctx; + + const ungroupedProjects = db + .select({ + id: projects.id, + name: projects.name, + repoPath: projects.repoPath, + icon: projects.icon, + color: projects.color, + }) + .from(projects) + .where( + notExists( + db + .select({ one: sql`1` }) + .from(projectGroupMembers) + .where(eq(projectGroupMembers.projectId, projects.id)), + ), + ) + .all(); + + if (ungroupedProjects.length === 0) return { groups: 0, members: 0 }; + + const now = Date.now(); + const projectIdByRepoPath = new Map( + db + .select({ id: projects.id, repoPath: projects.repoPath }) + .from(projects) + .all() + .map((row) => [row.repoPath, row.id]), + ); + const foldersByProject = new Map< + string, + { + position: number; + folder: string; + repoPath: string | null; + baseBranch: string | null; + }[] + >(); + for (const row of db.select().from(projectFolders).all()) { + const folders = foldersByProject.get(row.projectId) ?? []; + folders.push({ + position: row.position, + folder: row.folder, + repoPath: row.repoPath, + baseBranch: row.baseBranch, + }); + foldersByProject.set(row.projectId, folders); + } + + let members = 0; + db.transaction((tx) => { + for (const project of ungroupedProjects) { + const groupId = randomUUID(); + tx.insert(projectGroups) + .values({ + id: groupId, + name: project.name || basename(project.repoPath), + icon: project.icon, + color: project.color, + createdAt: now, + updatedAt: now, + }) + .run(); + + const values: MemberValues[] = []; + const claimedProjectIds = new Set(); + for (const folder of (foldersByProject.get(project.id) ?? []).sort( + (a, b) => a.position - b.position, + )) { + const memberProjectId = + folder.position === 0 + ? project.id + : folder.repoPath + ? projectIdByRepoPath.get(folder.repoPath) + : undefined; + if (!memberProjectId || claimedProjectIds.has(memberProjectId)) + continue; + claimedProjectIds.add(memberProjectId); + values.push({ + id: randomUUID(), + groupId, + projectId: memberProjectId, + position: values.length, + folder: folder.folder, + baseBranch: folder.baseBranch, + createdAt: now, + }); + } + if (values.length === 0) { + values.push({ + id: randomUUID(), + groupId, + projectId: project.id, + position: 0, + folder: defaultFolderNameForRepo(project.repoPath), + baseBranch: null, + createdAt: now, + }); + } + + tx.insert(projectGroupMembers).values(values).onConflictDoNothing().run(); + members += values.length; + } + }); + + return { groups: ungroupedProjects.length, members }; +} diff --git a/packages/host-service/src/trpc/router/project-groups/index.ts b/packages/host-service/src/trpc/router/project-groups/index.ts new file mode 100644 index 00000000000..c1929733d71 --- /dev/null +++ b/packages/host-service/src/trpc/router/project-groups/index.ts @@ -0,0 +1 @@ +export { projectGroupsRouter } from "./project-groups"; diff --git a/packages/host-service/src/trpc/router/project-groups/project-groups.test.ts b/packages/host-service/src/trpc/router/project-groups/project-groups.test.ts new file mode 100644 index 00000000000..dc22d687480 --- /dev/null +++ b/packages/host-service/src/trpc/router/project-groups/project-groups.test.ts @@ -0,0 +1,233 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, it } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { HostDb } from "../../../db"; +import * as schema from "../../../db/schema"; +import { + projectGroupMembers, + projectGroups, + projects, +} from "../../../db/schema"; +import type { HostServiceContext } from "../../../types"; +import { createCallerFactory } from "../../index"; +import { projectGroupsRouter } from "./project-groups"; + +const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../../../drizzle"); + +function createHarness() { + const sqlite = new Database(":memory:"); + sqlite.run("PRAGMA foreign_keys = OFF"); + const rawDb = drizzle(sqlite, { schema }); + migrate(rawDb, { migrationsFolder: MIGRATIONS_FOLDER }); + sqlite.run("PRAGMA foreign_keys = ON"); + const db = rawDb as unknown as HostDb; + const ctx = { db, isAuthenticated: true } as unknown as HostServiceContext; + return { db, caller: createCallerFactory(projectGroupsRouter)(ctx) }; +} + +function insertProject(db: HostDb, repoPath: string): string { + const id = randomUUID(); + db.insert(projects) + .values({ id, repoPath, name: repoPath, updatedAt: 1 }) + .run(); + return id; +} + +describe("projectGroups router", () => { + it("lets the same project belong to any number of groups", async () => { + const h = createHarness(); + const projectId = insertProject(h.db, "/home/me/code/api"); + const first = await h.caller.create({ name: "Platform" }); + const second = await h.caller.create({ name: "Billing" }); + + await h.caller.addMember({ groupId: first.group.id, projectId }); + await h.caller.addMember({ groupId: second.group.id, projectId }); + + const { groups } = await h.caller.list(); + expect( + groups.map((group) => group.members.map((m) => m.projectId)), + ).toEqual([[projectId], [projectId]]); + }); + + it("rejects the same project twice inside one group", async () => { + const h = createHarness(); + const projectId = insertProject(h.db, "/home/me/code/api"); + const { group } = await h.caller.create({ name: "Platform" }); + await h.caller.addMember({ groupId: group.id, projectId }); + + await expect( + h.caller.addMember({ groupId: group.id, projectId }), + ).rejects.toThrow("already a member"); + }); + + it("blocks deleting a project a member still references", async () => { + const h = createHarness(); + const projectId = insertProject(h.db, "/home/me/code/api"); + const { group } = await h.caller.create({ name: "Platform" }); + const added = await h.caller.addMember({ groupId: group.id, projectId }); + + expect(() => + h.db.delete(projects).where(eq(projects.id, projectId)).run(), + ).toThrow(/FOREIGN KEY/i); + + await h.caller.removeMember({ + groupId: group.id, + memberId: added.group.members[0]?.id ?? "", + }); + h.db.delete(projects).where(eq(projects.id, projectId)).run(); + expect(h.db.select().from(projects).all()).toHaveLength(0); + }); + + it("defaults the folder to the repo directory and deduplicates it", async () => { + const h = createHarness(); + const { group } = await h.caller.create({ name: "Platform" }); + const first = insertProject(h.db, "/home/me/code/api"); + const second = insertProject(h.db, "/home/me/other/api"); + const third = insertProject(h.db, "/home/me/code/web"); + + await h.caller.addMember({ groupId: group.id, projectId: first }); + await h.caller.addMember({ groupId: group.id, projectId: second }); + const after = await h.caller.addMember({ + groupId: group.id, + projectId: third, + folder: "API", + }); + + expect(after.group.members.map((member) => member.folder)).toEqual([ + "api", + "api-2", + "API-3", + ]); + }); + + it("rejects a folder that is not one path segment", async () => { + const h = createHarness(); + const { group } = await h.caller.create({ name: "Platform" }); + const projectId = insertProject(h.db, "/home/me/code/api"); + + await expect( + h.caller.addMember({ groupId: group.id, projectId, folder: "../etc" }), + ).rejects.toThrow("single directory name"); + }); + + it("promotes a member to position 0 and keeps the rest in order", async () => { + const h = createHarness(); + const { group } = await h.caller.create({ name: "Platform" }); + for (const repo of ["api", "web", "docs"]) { + await h.caller.addMember({ + groupId: group.id, + projectId: insertProject(h.db, `/home/me/code/${repo}`), + }); + } + const before = await h.caller.get({ groupId: group.id }); + const docs = before.group.members[2]; + + const after = await h.caller.setPrimary({ + groupId: group.id, + memberId: docs?.id ?? "", + }); + + expect( + after.group.members.map((member) => [member.position, member.folder]), + ).toEqual([ + [0, "docs"], + [1, "api"], + [2, "web"], + ]); + }); + + it("refuses to remove the primary while other members remain", async () => { + const h = createHarness(); + const { group } = await h.caller.create({ name: "Platform" }); + await h.caller.addMember({ + groupId: group.id, + projectId: insertProject(h.db, "/home/me/code/api"), + }); + const filled = await h.caller.addMember({ + groupId: group.id, + projectId: insertProject(h.db, "/home/me/code/web"), + }); + const primary = filled.group.members[0]; + + await expect( + h.caller.removeMember({ + groupId: group.id, + memberId: primary?.id ?? "", + }), + ).rejects.toThrow("make another member primary first"); + + const promoted = await h.caller.setPrimary({ + groupId: group.id, + memberId: filled.group.members[1]?.id ?? "", + }); + const removed = await h.caller.removeMember({ + groupId: group.id, + memberId: primary?.id ?? "", + }); + expect(promoted.group.members[0]?.folder).toBe("web"); + expect(removed.group.members.map((member) => member.folder)).toEqual([ + "web", + ]); + }); + + it("removes the last member without promotion", async () => { + const h = createHarness(); + const { group } = await h.caller.create({ name: "Platform" }); + const added = await h.caller.addMember({ + groupId: group.id, + projectId: insertProject(h.db, "/home/me/code/api"), + }); + + const after = await h.caller.removeMember({ + groupId: group.id, + memberId: added.group.members[0]?.id ?? "", + }); + + expect(after.group.members).toEqual([]); + }); + + it("renames a group and reports it back", async () => { + const h = createHarness(); + const { group } = await h.caller.create({ name: " Platform " }); + expect(group.name).toBe("Platform"); + + const renamed = await h.caller.rename({ + groupId: group.id, + name: "Infrastructure", + }); + + expect(renamed.group.name).toBe("Infrastructure"); + expect((await h.caller.get({ groupId: group.id })).group.name).toBe( + "Infrastructure", + ); + }); + + it("deletes the group and its members, never a project", async () => { + const h = createHarness(); + const projectId = insertProject(h.db, "/home/me/code/api"); + const { group } = await h.caller.create({ name: "Platform" }); + await h.caller.addMember({ groupId: group.id, projectId }); + + await h.caller.remove({ groupId: group.id }); + + expect(h.db.select().from(projectGroups).all()).toHaveLength(0); + expect(h.db.select().from(projectGroupMembers).all()).toHaveLength(0); + expect(h.db.select().from(projects).all()).toHaveLength(1); + await expect(h.caller.get({ groupId: group.id })).rejects.toThrow( + "Project group not found", + ); + }); + + it("rejects a member whose project is not on this host", async () => { + const h = createHarness(); + const { group } = await h.caller.create({ name: "Platform" }); + + await expect( + h.caller.addMember({ groupId: group.id, projectId: randomUUID() }), + ).rejects.toThrow("not set up on this host"); + }); +}); diff --git a/packages/host-service/src/trpc/router/project-groups/project-groups.ts b/packages/host-service/src/trpc/router/project-groups/project-groups.ts new file mode 100644 index 00000000000..8f08f2882fe --- /dev/null +++ b/packages/host-service/src/trpc/router/project-groups/project-groups.ts @@ -0,0 +1,271 @@ +import { randomUUID } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { + projectGroupMembers, + projectGroups, + projects, + workspaces, +} from "../../../db/schema"; +import { + deduplicateFolderName, + defaultFolderNameForRepo, + sanitizeFolderName, +} from "../../../projects/project-folders"; +import { + getProjectGroup, + listProjectGroups, + type ProjectGroup, + type ProjectGroupMember, + reassignMemberPositions, +} from "../../../projects/project-groups"; +import type { HostServiceContext } from "../../../types"; +import { protectedProcedure, router } from "../../index"; + +const MAX_GROUP_NAME_LENGTH = 200; + +const memberShape = z.object({ + id: z.string(), + groupId: z.string(), + projectId: z.string(), + position: z.number(), + folder: z.string(), + baseBranch: z.string().nullable(), +}); + +const groupShape = z.object({ + id: z.string(), + name: z.string(), + icon: z.string().nullable(), + color: z.string().nullable(), + createdAt: z.number(), + updatedAt: z.number(), + members: z.array(memberShape), +}); + +export type ProjectGroupMemberResponse = z.infer; +export type ProjectGroupResponse = z.infer; + +function toResponse(group: ProjectGroup): ProjectGroupResponse { + return { + id: group.id, + name: group.name, + icon: group.icon, + color: group.color, + createdAt: group.createdAt, + updatedAt: group.updatedAt, + members: group.members.map((member) => ({ + id: member.id, + groupId: member.groupId, + projectId: member.projectId, + position: member.position, + folder: member.folder, + baseBranch: member.baseBranch, + })), + }; +} + +function requireGroup(ctx: HostServiceContext, groupId: string): ProjectGroup { + const group = getProjectGroup(ctx.db, groupId); + if (!group) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project group not found: ${groupId}`, + }); + } + return group; +} + +function requireGroupName(input: string): string { + const trimmed = input.trim(); + if (!trimmed || trimmed.length > MAX_GROUP_NAME_LENGTH) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Name must be 1 to ${MAX_GROUP_NAME_LENGTH} characters`, + }); + } + return trimmed; +} + +function requireFolderName(input: string): string { + const sanitized = sanitizeFolderName(input); + if (!sanitized) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Folder must be a single directory name of letters, digits, dot, dash or underscore", + }); + } + return sanitized; +} + +function findMember(group: ProjectGroup, memberId: string): ProjectGroupMember { + const member = group.members.find((candidate) => candidate.id === memberId); + if (!member) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Member not found in this group: ${memberId}`, + }); + } + return member; +} + +export const projectGroupsRouter = router({ + list: protectedProcedure.query( + ({ ctx }): { groups: ProjectGroupResponse[] } => ({ + groups: listProjectGroups(ctx.db).map(toResponse), + }), + ), + + get: protectedProcedure + .input(z.object({ groupId: z.string().min(1) })) + .query(({ ctx, input }): { group: ProjectGroupResponse } => ({ + group: toResponse(requireGroup(ctx, input.groupId)), + })), + + create: protectedProcedure + .input(z.object({ name: z.string().min(1) })) + .mutation(({ ctx, input }): { group: ProjectGroupResponse } => { + const name = requireGroupName(input.name); + const now = Date.now(); + const id = randomUUID(); + ctx.db + .insert(projectGroups) + .values({ id, name, createdAt: now, updatedAt: now }) + .run(); + return { group: toResponse(requireGroup(ctx, id)) }; + }), + + rename: protectedProcedure + .input(z.object({ groupId: z.string().min(1), name: z.string().min(1) })) + .mutation(({ ctx, input }): { group: ProjectGroupResponse } => { + requireGroup(ctx, input.groupId); + ctx.db + .update(projectGroups) + .set({ name: requireGroupName(input.name), updatedAt: Date.now() }) + .where(eq(projectGroups.id, input.groupId)) + .run(); + return { group: toResponse(requireGroup(ctx, input.groupId)) }; + }), + + remove: protectedProcedure + .input(z.object({ groupId: z.string().min(1) })) + .mutation(({ ctx, input }): { groupId: string } => { + requireGroup(ctx, input.groupId); + ctx.db.transaction((tx) => { + tx.update(workspaces) + .set({ groupId: null }) + .where(eq(workspaces.groupId, input.groupId)) + .run(); + tx.delete(projectGroupMembers) + .where(eq(projectGroupMembers.groupId, input.groupId)) + .run(); + tx.delete(projectGroups) + .where(eq(projectGroups.id, input.groupId)) + .run(); + }); + return { groupId: input.groupId }; + }), + + addMember: protectedProcedure + .input( + z.object({ + groupId: z.string().min(1), + projectId: z.string().min(1), + folder: z.string().min(1).optional(), + baseBranch: z.string().min(1).optional(), + }), + ) + .mutation(({ ctx, input }): { group: ProjectGroupResponse } => { + const group = requireGroup(ctx, input.groupId); + const project = ctx.db + .select({ id: projects.id, repoPath: projects.repoPath }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project is not set up on this host", + }); + } + if (group.members.some((member) => member.projectId === project.id)) { + throw new TRPCError({ + code: "CONFLICT", + message: "Project is already a member of this group", + }); + } + + const requested = input.folder + ? requireFolderName(input.folder) + : defaultFolderNameForRepo(project.repoPath); + const folder = deduplicateFolderName( + requested, + group.members.map((member) => member.folder), + ); + + ctx.db + .insert(projectGroupMembers) + .values({ + id: randomUUID(), + groupId: group.id, + projectId: project.id, + position: group.members.length, + folder, + baseBranch: input.baseBranch ?? null, + createdAt: Date.now(), + }) + .run(); + return { group: toResponse(requireGroup(ctx, group.id)) }; + }), + + removeMember: protectedProcedure + .input( + z.object({ groupId: z.string().min(1), memberId: z.string().min(1) }), + ) + .mutation(({ ctx, input }): { group: ProjectGroupResponse } => { + const group = requireGroup(ctx, input.groupId); + const member = findMember(group, input.memberId); + if (member.position === 0 && group.members.length > 1) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `"${member.folder}" is the primary folder — make another member primary first`, + }); + } + ctx.db + .delete(projectGroupMembers) + .where( + and( + eq(projectGroupMembers.id, member.id), + eq(projectGroupMembers.groupId, group.id), + ), + ) + .run(); + reassignMemberPositions( + ctx.db, + group.members + .filter((candidate) => candidate.id !== member.id) + .map((candidate) => candidate.id), + ); + return { group: toResponse(requireGroup(ctx, group.id)) }; + }), + + setPrimary: protectedProcedure + .input( + z.object({ groupId: z.string().min(1), memberId: z.string().min(1) }), + ) + .mutation(({ ctx, input }): { group: ProjectGroupResponse } => { + const group = requireGroup(ctx, input.groupId); + const member = findMember(group, input.memberId); + if (member.position !== 0) { + reassignMemberPositions(ctx.db, [ + member.id, + ...group.members + .filter((candidate) => candidate.id !== member.id) + .map((candidate) => candidate.id), + ]); + } + return { group: toResponse(requireGroup(ctx, group.id)) }; + }), +}); diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index d50930ddd10..42ef8c0ac09 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -14,6 +14,7 @@ import { notificationsRouter } from "./notifications"; import { pageWatchRouter } from "./page-watch"; import { portsRouter } from "./ports"; import { projectRouter } from "./project"; +import { projectGroupsRouter } from "./project-groups"; import { pullRequestsRouter } from "./pull-requests"; import { sandboxRouter } from "./sandbox"; import { settingsRouter } from "./settings"; @@ -42,6 +43,7 @@ export const appRouter = router({ notifications: notificationsRouter, pullRequests: pullRequestsRouter, project: projectRouter, + projectGroups: projectGroupsRouter, tagFolders: tagFoldersRouter, pageWatch: pageWatchRouter, ports: portsRouter, From 56dd38d7ea6d77ea6b81fc800f6578cb1f1efc6e Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 21 Sep 2026 07:41:17 +0530 Subject: [PATCH 05/14] feat(desktop): surface the project container behind the multi-repo flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read projects from `projectGroups` when the flag is on: create a Project and add every source folder identically, edit its folders in settings, and show a repo-count badge in the sidebar. Flag off reads the repository list exactly as before — the sidebar overlay is simply not applied, and the equivalence for a backfilled single-member group is asserted in a test rather than assumed. Follows Codex's vocabulary: the container is a "Project", its members are "Source folders", and a repository stops being user-facing. Claude-Session: https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon --- .../useHostProjectGroups/index.ts | 13 ++ .../useHostProjectGroups.ts | 86 +++++++++++ .../useHostProjectGroups.utils.test.ts | 94 ++++++++++++ .../useHostProjectGroups.utils.ts | 83 +++++++++++ .../DashboardSidebarProjectSection.tsx | 1 + .../DashboardSidebarProjectRow.tsx | 30 ++++ ...seDashboardSidebarProjectSectionActions.ts | 30 ++-- ...pplyProjectGroupsToSidebarProjects.test.ts | 141 ++++++++++++++++++ .../applyProjectGroupsToSidebarProjects.ts | 34 +++++ .../buildDashboardSidebarProjects.ts | 2 + .../useDashboardSidebarData.ts | 21 ++- .../components/DashboardSidebar/types.ts | 2 + .../MultiRepoProjectModal.test.tsx | 108 +++++++++++++- .../MultiRepoProjectModal.tsx | 106 ++++++------- .../MultiRepoProjectModal.utils.ts | 100 +++++++++++++ .../V2ProjectSettings.test.tsx | 5 + .../V2ProjectSettings/V2ProjectSettings.tsx | 34 ++++- .../components/NameSection/NameSection.tsx | 19 ++- .../SourceFoldersSection.test.tsx | 70 ++++++++- .../SourceFoldersSection.tsx | 101 ++++++++++--- .../SourceFoldersSection.utils.ts | 37 +++++ .../SourceFolderRow/SourceFolderRow.tsx | 2 +- .../SourceFolderMenuItems.tsx | 24 +-- 23 files changed, 1034 insertions(+), 109 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/index.ts create mode 100644 apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.ts create mode 100644 apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.test.ts create mode 100644 apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.ts diff --git a/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/index.ts b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/index.ts new file mode 100644 index 00000000000..f73459d1237 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/index.ts @@ -0,0 +1,13 @@ +export { + type HostProjectGroup, + type HostProjectGroupMember, + type UseHostProjectGroupsResult, + useHostProjectGroups, +} from "./useHostProjectGroups"; +export { + collectSourceFolderOnlyProjectIds, + findPrimaryMember, + findProjectGroupForProject, + HOST_PROJECT_GROUPS_QUERY_PREFIX, + indexProjectGroupsByPrimaryProjectId, +} from "./useHostProjectGroups.utils"; diff --git a/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.ts b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.ts new file mode 100644 index 00000000000..507660dae5e --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.ts @@ -0,0 +1,86 @@ +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { useKnownHosts } from "renderer/hooks/known-hosts/useKnownHosts"; +import { useRelayUrl } from "renderer/hooks/useRelayUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { MOCK_ORG_ID } from "shared/constants"; +import { deriveHostProjectsQueryTargets } from "../useHostProjects/useHostProjects.utils"; +import { + getHostProjectGroupsQueryKey, + type HostProjectGroup, +} from "./useHostProjectGroups.utils"; + +export type { + HostProjectGroup, + HostProjectGroupMember, +} from "./useHostProjectGroups.utils"; + +const PROJECT_GROUPS_REFETCH_INTERVAL_MS = 30_000; + +export interface UseHostProjectGroupsResult { + groups: HostProjectGroup[]; + isReady: boolean; +} + +export function useHostProjectGroups({ + enabled, +}: { + enabled: boolean; +}): UseHostProjectGroupsResult { + const { activeHostUrl, machineId, activeOrganizationId } = + useLocalHostService(); + const relayUrl = useRelayUrl(); + const fallbackOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (activeOrganizationId ?? null); + const { hosts, settled: knownHostsSettled } = useKnownHosts(); + + const targets = useMemo( + () => + deriveHostProjectsQueryTargets({ + activeHostUrl, + hosts, + machineId, + relayUrl, + fallbackOrganizationId, + }), + [activeHostUrl, hosts, machineId, relayUrl, fallbackOrganizationId], + ); + + const queries = useQueries({ + queries: targets.map((target) => ({ + queryKey: getHostProjectGroupsQueryKey(target), + enabled: enabled && target.hostUrl !== null, + refetchInterval: PROJECT_GROUPS_REFETCH_INTERVAL_MS, + networkMode: "always" as const, + retry: 1, + queryFn: async (): Promise => { + if (!target.hostUrl) return []; + const client = getHostServiceClientByUrl(target.hostUrl); + const { groups } = await client.projectGroups.list.query(); + return groups.map((group) => ({ + ...group, + hostId: target.machineId, + })); + }, + })), + }); + + const groups = useMemo( + () => queries.flatMap((query) => query.data ?? []), + [queries], + ); + + const isReady = + enabled && + knownHostsSettled && + targets.length > 0 && + queries.every( + (query, index) => + query.isSuccess || query.isError || targets[index]?.hostUrl === null, + ); + + return { groups, isReady }; +} diff --git a/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.test.ts b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.test.ts new file mode 100644 index 00000000000..064e5b62b8b --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { + collectSourceFolderOnlyProjectIds, + findProjectGroupForProject, + type HostProjectGroup, + indexProjectGroupsByPrimaryProjectId, +} from "./useHostProjectGroups.utils"; + +function makeGroup( + id: string, + name: string, + memberProjectIds: string[], +): HostProjectGroup { + return { + id, + hostId: "host-1", + name, + icon: null, + color: null, + createdAt: 1, + updatedAt: 1, + members: memberProjectIds.map((projectId, position) => ({ + id: `${id}-m${position}`, + groupId: id, + projectId, + position, + folder: projectId, + baseBranch: null, + })), + }; +} + +const BACKFILLED = makeGroup("group-api", "api", ["project-api"]); +const MULTI = makeGroup("group-platform", "Platform", [ + "project-api", + "project-web", +]); + +describe("the project a repository is the primary of", () => { + test("is the one the surfaces render it as", () => { + const index = indexProjectGroupsByPrimaryProjectId([BACKFILLED, MULTI]); + + expect(index.get("project-api")).toBe(BACKFILLED); + expect(index.get("project-web")).toBeUndefined(); + }); + + test("is the first group listed when two claim the same primary", () => { + const other = makeGroup("group-other", "Other", ["project-api"]); + const index = indexProjectGroupsByPrimaryProjectId([BACKFILLED, other]); + + expect(index.get("project-api")).toBe(BACKFILLED); + }); +}); + +describe("a repository that is only someone's source folder", () => { + test("is collected so nothing lists it as a project of its own", () => { + expect(collectSourceFolderOnlyProjectIds([MULTI])).toEqual( + new Set(["project-web"]), + ); + }); + + test("is not collected once it is the primary of some project", () => { + expect(collectSourceFolderOnlyProjectIds([BACKFILLED, MULTI])).toEqual( + new Set(["project-web"]), + ); + expect( + collectSourceFolderOnlyProjectIds([ + MULTI, + makeGroup("group-web", "web", ["project-web"]), + ]), + ).toEqual(new Set()); + }); +}); + +describe("the project a settings page belongs to", () => { + test("prefers the one the repository is primary of", () => { + const borrowing = makeGroup("group-tools", "Tools", [ + "project-web", + "project-api", + ]); + + expect( + findProjectGroupForProject([borrowing, BACKFILLED], "project-api"), + ).toBe(BACKFILLED); + }); + + test("falls back to one that merely contains the repository", () => { + expect(findProjectGroupForProject([MULTI], "project-web")).toBe(MULTI); + }); + + test("is null for a repository no project has picked up yet", () => { + expect(findProjectGroupForProject([MULTI], "project-new")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.ts b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.ts new file mode 100644 index 00000000000..da289356a27 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.ts @@ -0,0 +1,83 @@ +import type { HostProjectsQueryTarget } from "../useHostProjects/useHostProjects.utils"; + +export interface HostProjectGroupMember { + id: string; + groupId: string; + projectId: string; + position: number; + folder: string; + baseBranch: string | null; +} + +export interface HostProjectGroup { + id: string; + hostId: string; + name: string; + icon: string | null; + color: string | null; + createdAt: number; + updatedAt: number; + members: HostProjectGroupMember[]; +} + +export const HOST_PROJECT_GROUPS_QUERY_PREFIX = [ + "host-service", + "project-groups", + "list", +] as const; + +export function getHostProjectGroupsQueryKey( + target: Pick, +) { + return [ + ...HOST_PROJECT_GROUPS_QUERY_PREFIX, + target.organizationId, + target.machineId, + ] as const; +} + +export function findPrimaryMember( + group: HostProjectGroup, +): HostProjectGroupMember | null { + return group.members.find((member) => member.position === 0) ?? null; +} + +export function indexProjectGroupsByPrimaryProjectId( + groups: HostProjectGroup[], +): Map { + const byProjectId = new Map(); + for (const group of groups) { + const primary = findPrimaryMember(group); + if (!primary || byProjectId.has(primary.projectId)) continue; + byProjectId.set(primary.projectId, group); + } + return byProjectId; +} + +export function collectSourceFolderOnlyProjectIds( + groups: HostProjectGroup[], +): Set { + const primaryProjectIds = new Set(); + const memberProjectIds = new Set(); + for (const group of groups) { + for (const member of group.members) { + memberProjectIds.add(member.projectId); + if (member.position === 0) primaryProjectIds.add(member.projectId); + } + } + for (const projectId of primaryProjectIds) memberProjectIds.delete(projectId); + return memberProjectIds; +} + +export function findProjectGroupForProject( + groups: HostProjectGroup[], + projectId: string, +): HostProjectGroup | null { + return ( + indexProjectGroupsByPrimaryProjectId(groups).get(projectId) ?? + groups.find((group) => + group.members.some((member) => member.projectId === projectId), + ) ?? + null + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index f6f7aa68b5b..36edf5252fb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -143,6 +143,7 @@ export function DashboardSidebarProjectSection({ projectName={project.name} iconUrl={project.iconUrl} projectColor={project.color} + repoCount={project.repoCount} isCollapsed={project.isCollapsed} isRenaming={isRenaming} renameValue={renameValue} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx index 94e8d8db20f..acf033fa527 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx @@ -1,3 +1,4 @@ +import { plural } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; @@ -11,6 +12,7 @@ interface DashboardSidebarProjectRowProps projectName: string; iconUrl: string | null; projectColor: string | null; + repoCount?: number; isCollapsed: boolean; isRenaming: boolean; renameValue: string; @@ -31,6 +33,7 @@ export const DashboardSidebarProjectRow = forwardRef< projectName, iconUrl, projectColor, + repoCount, isCollapsed, isRenaming, renameValue, @@ -97,6 +100,33 @@ export const DashboardSidebarProjectRow = forwardRef< ) : ( {projectName} )} + {!isRenaming && repoCount !== undefined && repoCount > 1 && ( + + + + {repoCount} + + + + {t({ + message: plural(repoCount, { + one: "# source folder", + other: "# source folders", + }), + })} + + + )} {!isRenaming && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts index 16d172041a5..451da75334f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts @@ -2,8 +2,10 @@ import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { errorMessage } from "@superset/i18n/errors"; import { toast } from "@superset/ui/sonner"; +import { useQueryClient } from "@tanstack/react-query"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useMemo, useRef, useState } from "react"; +import { HOST_PROJECT_GROUPS_QUERY_PREFIX } from "renderer/hooks/host-projects/useHostProjectGroups"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useOpenNewWorkspace } from "renderer/hooks/useOpenNewWorkspace"; @@ -28,6 +30,7 @@ export function useDashboardSidebarProjectSectionActions({ const { t } = useLingui(); const openNewWorkspace = useOpenNewWorkspace(); const navigate = useNavigate(); + const queryClient = useQueryClient(); // Renames commit on a host serving the project — host.db owns the name. // Prefer the local host when it serves the project (always reachable); // hostIds order is arbitrary and may lead with an offline remote. @@ -108,15 +111,24 @@ export function useDashboardSidebarProjectSectionActions({ ); return; } - void getHostServiceClientByUrl(servingHostUrl) - .project.update.mutate({ projectId: project.id, name: trimmed }) - .catch((err) => { - toast.error( - t({ - message: `Rename failed: ${errorMessage(err)}`, - }), - ); - }); + const host = getHostServiceClientByUrl(servingHostUrl); + void ( + project.groupId + ? host.projectGroups.rename + .mutate({ groupId: project.groupId, name: trimmed }) + .then(() => + queryClient.invalidateQueries({ + queryKey: HOST_PROJECT_GROUPS_QUERY_PREFIX, + }), + ) + : host.project.update.mutate({ projectId: project.id, name: trimmed }) + ).catch((err) => { + toast.error( + t({ + message: `Rename failed: ${errorMessage(err)}`, + }), + ); + }); }; const handleOpenInFinder = async () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.test.ts new file mode 100644 index 00000000000..7eded528c04 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import type { HostProjectGroup } from "renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils"; +import { applyProjectGroupsToSidebarProjects } from "./applyProjectGroupsToSidebarProjects"; +import { + buildDashboardSidebarProjects, + type SidebarProjectInput, + type SidebarWorkspaceInput, +} from "./buildDashboardSidebarProjects"; + +const MACHINE_ID = "machine-1"; +const DATE = new Date("2026-01-01T00:00:00.000Z"); + +function makeProject( + overrides: Partial = {}, +): SidebarProjectInput { + return { + id: "project-api", + name: "api", + githubOwner: null, + githubRepoName: null, + iconUrl: null, + color: null, + createdAt: DATE, + updatedAt: DATE, + isCollapsed: false, + ...overrides, + }; +} + +function makeWorkspace( + overrides: Partial = {}, +): SidebarWorkspaceInput { + return { + id: "workspace-1", + projectId: "project-api", + hostId: MACHINE_ID, + type: "worktree", + hostIsOnline: true, + name: "Workspace", + branch: "main", + taskId: null, + createdAt: DATE, + updatedAt: DATE, + lastActivityAt: null, + tabOrder: 1, + sectionId: null, + pinnedAt: null, + pendingTransaction: null, + ...overrides, + }; +} + +function makeGroup( + id: string, + name: string, + memberProjectIds: string[], +): HostProjectGroup { + return { + id, + hostId: MACHINE_ID, + name, + icon: null, + color: null, + createdAt: 1, + updatedAt: 1, + members: memberProjectIds.map((projectId, position) => ({ + id: `${id}-m${position}`, + groupId: id, + projectId, + position, + folder: projectId, + baseBranch: null, + })), + }; +} + +function build(sidebarProjects: SidebarProjectInput[]) { + return buildDashboardSidebarProjects({ + sidebarProjects, + sidebarSections: [], + visibleSidebarWorkspaces: [makeWorkspace()], + machineId: MACHINE_ID, + pullRequestsByWorkspaceId: new Map(), + }); +} + +describe("a project backfilled from a single repository", () => { + it("renders exactly as the repository did before it had a container", () => { + const projects = [makeProject()]; + const flagOff = build(projects); + const flagOn = build( + applyProjectGroupsToSidebarProjects(projects, [ + makeGroup("group-api", "api", ["project-api"]), + ]), + ); + + expect(flagOn.map(({ groupId, repoCount, ...project }) => project)).toEqual( + flagOff, + ); + expect(flagOn[0]?.repoCount).toBe(1); + expect(flagOn[0]?.groupId).toBe("group-api"); + }); + + it("keeps rendering when no host has grouped it yet", () => { + const grouped = applyProjectGroupsToSidebarProjects([makeProject()], []); + + expect(grouped).toHaveLength(1); + expect(grouped[0]).toMatchObject({ + id: "project-api", + name: "api", + groupId: null, + repoCount: 1, + }); + }); +}); + +describe("a project over several repositories", () => { + it("renders once, under its own name, counting its source folders", () => { + const grouped = applyProjectGroupsToSidebarProjects( + [makeProject(), makeProject({ id: "project-web", name: "web" })], + [makeGroup("group-platform", "Platform", ["project-api", "project-web"])], + ); + + expect(grouped).toHaveLength(1); + expect(grouped[0]?.id).toBe("project-api"); + expect(grouped[0]?.name).toBe("Platform"); + expect(grouped[0]?.repoCount).toBe(2); + }); + + it("still renders a source folder that is a project in its own right", () => { + const grouped = applyProjectGroupsToSidebarProjects( + [makeProject(), makeProject({ id: "project-web", name: "web" })], + [ + makeGroup("group-platform", "Platform", ["project-api", "project-web"]), + makeGroup("group-web", "web", ["project-web"]), + ], + ); + + expect(grouped.map((project) => project.name)).toEqual(["Platform", "web"]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.ts new file mode 100644 index 00000000000..1627ed714a0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.ts @@ -0,0 +1,34 @@ +import { + collectSourceFolderOnlyProjectIds, + type HostProjectGroup, + indexProjectGroupsByPrimaryProjectId, +} from "renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils"; + +export type GroupedSidebarProject = Project & { + groupId: string | null; + repoCount: number; +}; + +export function applyProjectGroupsToSidebarProjects< + Project extends { id: string; name: string }, +>( + projects: Project[], + groups: HostProjectGroup[], +): GroupedSidebarProject[] { + const groupByPrimaryProjectId = indexProjectGroupsByPrimaryProjectId(groups); + const sourceFolderOnlyProjectIds = collectSourceFolderOnlyProjectIds(groups); + + return projects.flatMap((project): GroupedSidebarProject[] => { + if (sourceFolderOnlyProjectIds.has(project.id)) return []; + const group = groupByPrimaryProjectId.get(project.id); + if (!group) return [{ ...project, groupId: null, repoCount: 1 }]; + return [ + { + ...project, + name: group.name, + groupId: group.id, + repoCount: group.members.length, + }, + ]; + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts index 796b0075f57..7b33a855e49 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts @@ -21,6 +21,8 @@ type SidebarPullRequest = DashboardSidebarWorkspace["pullRequest"]; export interface SidebarProjectInput { id: string; name: string; + groupId?: string | null; + repoCount?: number; githubOwner: string | null; githubRepoName: string | null; iconUrl: string | null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index c0b812c80a1..ef3288e1b93 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -1,7 +1,10 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; import { useLiveQuery } from "@tanstack/react-db"; import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useFeatureFlagEnabled } from "posthog-js/react"; import { useCallback, useMemo, useRef } from "react"; import { resolveProjectIconUrl } from "renderer/hooks/host-projects/resolveProjectIconUrl"; +import { useHostProjectGroups } from "renderer/hooks/host-projects/useHostProjectGroups"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useKnownHosts } from "renderer/hooks/known-hosts/useKnownHosts"; import { useRelayUrl } from "renderer/hooks/useRelayUrl"; @@ -22,6 +25,7 @@ import type { DashboardSidebarProject, DashboardSidebarWorkspace, } from "../../types"; +import { applyProjectGroupsToSidebarProjects } from "./applyProjectGroupsToSidebarProjects"; import { buildDashboardSidebarPinnedWorkspaces, buildDashboardSidebarProjects, @@ -212,7 +216,7 @@ export function useDashboardSidebarData() { () => new Map(hostProjects.map((project) => [project.projectKey, project])), [hostProjects], ); - const sidebarProjects = useMemo( + const repositorySidebarProjects = useMemo( () => orderedSidebarProjectRows.flatMap((row) => { // A hidden project keeps its placement rows but renders nowhere; @@ -238,6 +242,21 @@ export function useDashboardSidebarData() { }), [orderedSidebarProjectRows, hostProjectsByKey], ); + const isMultiRepoEnabled = + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? false; + const { groups: hostProjectGroups } = useHostProjectGroups({ + enabled: isMultiRepoEnabled, + }); + const sidebarProjects = useMemo( + () => + isMultiRepoEnabled + ? applyProjectGroupsToSidebarProjects( + repositorySidebarProjects, + hostProjectGroups, + ) + : repositorySidebarProjects, + [hostProjectGroups, isMultiRepoEnabled, repositorySidebarProjects], + ); const hiddenProjects = useMemo( () => orderedSidebarProjectRows.flatMap((row) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index 9ce46a9d475..40fb50c3a1b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -113,6 +113,8 @@ export interface DashboardSidebarHiddenProject { export interface DashboardSidebarProject { id: string; name: string; + groupId?: string | null; + repoCount?: number; githubOwner: string | null; githubRepoName: string | null; iconUrl: string | null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.test.tsx index 5bd8c2bda0c..76cd8bd5962 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.test.tsx @@ -15,9 +15,12 @@ const { act, cleanup, fireEvent, render, within } = await import( const { MultiRepoProjectForm } = await import( "./components/MultiRepoProjectForm" ); -const { appendFolder, removeFolder } = await import( - "./MultiRepoProjectModal.utils" -); +const { appendFolder, createProjectWithSourceFolders, removeFolder } = + await import("./MultiRepoProjectModal.utils"); + +type CreationClient = Parameters< + typeof createProjectWithSourceFolders +>[0]["client"]; afterEach(cleanup); afterAll(async () => { @@ -108,3 +111,102 @@ describe("removing a folder before creating", () => { ); }); }); + +function makeClient( + overrides: Partial = {}, +): CreationClient & { calls: string[] } { + const calls: string[] = []; + return { + calls, + createProject: async (name) => { + calls.push(`createProject:${name}`); + return { groupId: "group-1" }; + }, + resolveRepository: async (folder) => { + calls.push(`resolveRepository:${folder.path}`); + return { projectId: `project-${folder.name}`, repoPath: folder.path }; + }, + addSourceFolder: async (input) => { + calls.push(`addSourceFolder:${input.projectId}:${input.folder}`); + }, + ...overrides, + }; +} + +describe("creating a project", () => { + test("adds every folder the same way, with the first as the primary", async () => { + const client = makeClient(); + + const result = await createProjectWithSourceFolders({ + client, + name: "Platform", + folders: FOLDERS, + }); + + expect(client.calls).toEqual([ + "createProject:Platform", + "resolveRepository:/repos/api", + "addSourceFolder:project-api:api", + "resolveRepository:/repos/web", + "addSourceFolder:project-web:web", + ]); + expect(result).toMatchObject({ + status: "created", + groupId: "group-1", + primaryProjectId: "project-api", + primaryRepoPath: "/repos/api", + }); + }); + + test("keeps what the host accepted when a later folder fails", async () => { + const failure = new Error("not a repository"); + const client = makeClient({ + addSourceFolder: async (input) => { + if (input.projectId === "project-web") throw failure; + }, + }); + + const result = await createProjectWithSourceFolders({ + client, + name: "Platform", + folders: FOLDERS, + }); + + expect(result).toMatchObject({ + status: "failed", + groupId: "group-1", + error: failure, + }); + expect(result.attached.map((entry) => entry.path)).toEqual(["/repos/api"]); + expect(result.status === "failed" && result.folder?.name).toBe("web"); + }); + + test("resumes into the same project instead of creating a second one", async () => { + const client = makeClient(); + + const result = await createProjectWithSourceFolders({ + client, + name: "Platform", + folders: FOLDERS, + previousAttempt: { + groupId: "group-1", + attached: [ + { + path: "/repos/api", + projectId: "project-api", + repoPath: "/repos/api", + }, + ], + }, + }); + + expect(client.calls).toEqual([ + "resolveRepository:/repos/web", + "addSourceFolder:project-web:web", + ]); + expect(result).toMatchObject({ + status: "created", + primaryProjectId: "project-api", + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.tsx index 9b70a38b076..fde9375557f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.tsx @@ -8,7 +8,9 @@ import { DialogTitle, } from "@superset/ui/dialog"; import { toast } from "@superset/ui/sonner"; +import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import { HOST_PROJECT_GROUPS_QUERY_PREFIX } from "renderer/hooks/host-projects/useHostProjectGroups"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { showHostServiceUnavailableToast } from "renderer/lib/host-service-unavailable"; @@ -16,7 +18,10 @@ import { useFinalizeProjectSetup } from "renderer/react-query/projects"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { MultiRepoProjectForm } from "./components/MultiRepoProjectForm"; import { + type AttachedSourceFolder, appendFolder, + createProjectWithSourceFolders, + type ProjectCreationClient, removeFolder, type SelectedFolder, } from "./MultiRepoProjectModal.utils"; @@ -35,22 +40,23 @@ export function MultiRepoProjectModal({ const { t } = useLingui(); const hostService = useLocalHostService(); const finalizeSetup = useFinalizeProjectSetup(); + const queryClient = useQueryClient(); const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); const [name, setName] = useState(""); const [folders, setFolders] = useState([]); const [working, setWorking] = useState(false); const [failure, setFailure] = useState(null); - const [projectId, setProjectId] = useState(null); - const [committedPaths, setCommittedPaths] = useState([]); + const [groupId, setGroupId] = useState(null); + const [attached, setAttached] = useState([]); const reset = () => { setName(""); setFolders([]); setWorking(false); setFailure(null); - setProjectId(null); - setCommittedPaths([]); + setGroupId(null); + setAttached([]); }; const handleOpenChange = (next: boolean) => { @@ -90,53 +96,53 @@ export function MultiRepoProjectModal({ }); return; } - const client = getHostServiceClientByUrl(hostUrl); + const host = getHostServiceClientByUrl(hostUrl); + const client: ProjectCreationClient = { + createProject: async (projectName) => ({ + groupId: ( + await host.projectGroups.create.mutate({ name: projectName }) + ).group.id, + }), + resolveRepository: (folder) => + host.project.create.mutate({ + name: folder.name, + mode: { kind: "importLocal", repoPath: folder.path }, + }), + addSourceFolder: async (input) => { + await host.projectGroups.addMember.mutate(input); + }, + }; - const attached = new Set(committedPaths); - let createdProjectId = projectId; - if (!createdProjectId) { - try { - const result = await client.project.create.mutate({ - name: name.trim() || primary.name, - mode: { kind: "importLocal", repoPath: primary.path }, - }); - finalizeSetup(hostUrl, result); - createdProjectId = result.projectId; - setProjectId(createdProjectId); - } catch (error) { - setFailure( - t({ - message: `Could not create the project from "${primary.name}": ${errorMessage(error)}`, - }), - ); - return; - } - attached.add(primary.path); - setCommittedPaths([...attached]); - } + const result = await createProjectWithSourceFolders({ + client, + name: name.trim() || primary.name, + folders, + previousAttempt: { groupId, attached }, + }); + setGroupId(result.groupId); + setAttached(result.attached); + void queryClient.invalidateQueries({ + queryKey: HOST_PROJECT_GROUPS_QUERY_PREFIX, + }); - for (const folder of folders.slice(1)) { - if (attached.has(folder.path)) continue; - try { - await client.project.folders.add.mutate({ - projectId: createdProjectId, - repoPath: folder.path, - folder: folder.name, - }); - } catch (error) { - setCommittedPaths([...attached]); - setFailure( - t({ - message: `The project was created, but "${folder.name}" could not be added: ${errorMessage(error)}. The folders added before it were kept — remove it or fix it and create again.`, - }), - ); - return; - } - attached.add(folder.path); + if (result.status === "failed") { + setFailure( + result.folder + ? t({ + message: `"${result.folder.name}" could not be added: ${errorMessage(result.error)}. The source folders added before it were kept — remove it or fix it and create again.`, + }) + : t({ + message: `Could not create the project: ${errorMessage(result.error)}`, + }), + ); + return; } - setCommittedPaths([...attached]); - onSuccess({ projectId: createdProjectId }); + finalizeSetup(hostUrl, { + projectId: result.primaryProjectId, + repoPath: result.primaryRepoPath, + }); + onSuccess({ projectId: result.primaryProjectId }); reset(); onOpenChange(false); } catch (error) { @@ -155,8 +161,8 @@ export function MultiRepoProjectModal({ - Group several repositories into one project. Every workspace - checks out all of them. + Add the repositories this project works across as source folders. + Every workspace checks out all of them. @@ -164,7 +170,7 @@ export function MultiRepoProjectModal({ entry.path)} failure={failure} isWorking={working} isPicking={selectDirectory.isPending} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.utils.ts index 5611c93c7c6..b671ff4fd9f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/MultiRepoProjectModal/MultiRepoProjectModal.utils.ts @@ -39,3 +39,103 @@ export function removeFolder( ): SelectedFolder[] { return folders.filter((folder) => folder.path !== path); } + +export interface AttachedSourceFolder { + path: string; + projectId: string; + repoPath: string; +} + +export interface ProjectCreationClient { + createProject: (name: string) => Promise<{ groupId: string }>; + resolveRepository: ( + folder: SelectedFolder, + ) => Promise<{ projectId: string; repoPath: string }>; + addSourceFolder: (input: { + groupId: string; + projectId: string; + folder: string; + }) => Promise; +} + +export interface ProjectCreationAttempt { + groupId: string | null; + attached: AttachedSourceFolder[]; +} + +export type ProjectCreationResult = + | { + status: "created"; + groupId: string; + primaryProjectId: string; + primaryRepoPath: string; + attached: AttachedSourceFolder[]; + } + | { + status: "failed"; + groupId: string | null; + attached: AttachedSourceFolder[]; + folder: SelectedFolder | null; + error: unknown; + }; + +export async function createProjectWithSourceFolders({ + client, + name, + folders, + previousAttempt, +}: { + client: ProjectCreationClient; + name: string; + folders: SelectedFolder[]; + previousAttempt?: ProjectCreationAttempt; +}): Promise { + const attached = [...(previousAttempt?.attached ?? [])]; + let groupId = previousAttempt?.groupId ?? null; + + if (!groupId) { + try { + groupId = (await client.createProject(name)).groupId; + } catch (error) { + return { status: "failed", groupId: null, attached, folder: null, error }; + } + } + + for (const folder of folders) { + if (attached.some((entry) => entry.path === folder.path)) continue; + try { + const repository = await client.resolveRepository(folder); + await client.addSourceFolder({ + groupId, + projectId: repository.projectId, + folder: folder.name, + }); + attached.push({ + path: folder.path, + projectId: repository.projectId, + repoPath: repository.repoPath, + }); + } catch (error) { + return { status: "failed", groupId, attached, folder, error }; + } + } + + const primary = attached[0]; + if (!primary) { + return { + status: "failed", + groupId, + attached, + folder: null, + error: new Error("A project needs at least one source folder"), + }; + } + + return { + status: "created", + groupId, + primaryProjectId: primary.projectId, + primaryRepoPath: primary.repoPath, + attached, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx index 9600393ea05..6b7da1e4c2c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx @@ -61,6 +61,11 @@ mock.module("@tanstack/react-router", () => ({ mock.module("renderer/hooks/host-projects/useHostProjects", () => ({ useHostProjects: () => ({ projects: [project], isReady: true }), })); +mock.module("renderer/hooks/host-projects/useHostProjectGroups", () => ({ + useHostProjectGroups: () => ({ groups: [], isReady: true }), + findProjectGroupForProject: () => null, + HOST_PROJECT_GROUPS_QUERY_PREFIX: ["host-service", "project-groups", "list"], +})); mock.module("renderer/hooks/host-service/useHostTargetUrl", () => ({ useHostUrl: () => "http://127.0.0.1:7777", })); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index 0e4a47df35c..d8cb899bc36 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -9,6 +9,10 @@ import { PROJECT_ICON_NONE, resolveProjectIconUrl, } from "renderer/hooks/host-projects/resolveProjectIconUrl"; +import { + findProjectGroupForProject, + useHostProjectGroups, +} from "renderer/hooks/host-projects/useHostProjectGroups"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -47,12 +51,8 @@ export function V2ProjectSettings({ }: V2ProjectSettingsProps) { const navigate = useNavigate(); const { t } = useLingui(); - // TEMPORARY local-dev override — revert before committing. - // PostHog runs with a disabled key locally, so flags never resolve and every - // gated feature is invisible in dev. const isMultiRepoEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? - import.meta.env.DEV; + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? false; const { machineId } = useLocalHostService(); const { currentDeviceName, localHostId, otherHosts } = useWorkspaceHostOptions(); @@ -66,6 +66,18 @@ export function V2ProjectSettings({ [hostProjects, projectId], ); + const { groups: hostProjectGroups } = useHostProjectGroups({ + enabled: isMultiRepoEnabled, + }); + const projectGroup = useMemo( + () => + findProjectGroupForProject( + hostProjectGroups.filter((group) => group.hostId === targetHostId), + projectId, + ), + [hostProjectGroups, projectId, targetHostId], + ); + const hostOptions = useMemo(() => { const options: HostSelectOption[] = []; if (localHostId) { @@ -177,11 +189,13 @@ export function V2ProjectSettings({
-

{project.name}

+

+ {projectGroup?.name ?? project.name} +

{hasMultipleHosts && targetHostId ? ( refetchHostProject()} @@ -249,6 +266,7 @@ export function V2ProjectSettings({ {isMultiRepoEnabled && ( { @@ -38,8 +43,18 @@ export function NameSection({ if (!hostUrl) return; // Renames commit on the host — host.db owns the project name; the // project:changed event updates every open surface. - void getHostServiceClientByUrl(hostUrl) - .project.update.mutate({ projectId, name: trimmed }) + const host = getHostServiceClientByUrl(hostUrl); + void ( + groupId + ? host.projectGroups.rename + .mutate({ groupId, name: trimmed }) + .then(() => + queryClient.invalidateQueries({ + queryKey: HOST_PROJECT_GROUPS_QUERY_PREFIX, + }), + ) + : host.project.update.mutate({ projectId, name: trimmed }) + ) .then(() => onRenamed?.()) .catch((err) => { console.warn("[project-rename] host commit failed", err); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.test.tsx index ad7bc910170..98dbbcfcce3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.test.tsx @@ -19,7 +19,9 @@ const { SourceFolderRow } = await import("./components/SourceFolderRow"); const { SourceFolderMenuItems } = await import( "./components/SourceFolderRow/components/SourceFolderMenuItems" ); -const { withPrimaryFolder } = await import("./SourceFoldersSection.utils"); +const { toSourceFolders, withoutFolder, withPrimaryFolder } = await import( + "./SourceFoldersSection.utils" +); interface ProjectFolderShape { id: string; @@ -76,7 +78,7 @@ async function renderList(folders: ProjectFolderShape[]) { return within(view.baseElement as HTMLElement); } -async function renderMenu(isPrimary: boolean) { +async function renderMenu(isPrimary: boolean, canRenameFolder = true) { const onRemove = mock(() => {}); let view!: ReturnType; await act(async () => { @@ -86,7 +88,7 @@ async function renderMenu(isPrimary: boolean) { {}} - onRename={() => {}} + onRename={canRenameFolder ? () => {} : undefined} onRemove={onRemove} /> @@ -152,3 +154,65 @@ describe("making another folder primary", () => { expect(primaryRowName(ui)).toBe("web"); }); }); + +const MEMBERS = [ + { + id: "member-api", + projectId: "project-api", + position: 0, + folder: "api", + baseBranch: null, + }, + { + id: "member-web", + projectId: "project-web", + position: 1, + folder: "web", + baseBranch: null, + }, +]; + +const REPOSITORIES = [ + { id: "project-api", repoPath: "/repos/api", repoUrl: null }, + { id: "project-web", repoPath: "/repos/web", repoUrl: "git@github/web" }, +]; + +describe("a project's source folders", () => { + test("show each member's repository, ordered with the primary first", async () => { + const folders = toSourceFolders([...MEMBERS].reverse(), REPOSITORIES); + + expect(folders.map((folder) => folder.folder)).toEqual(["api", "web"]); + expect(folders.map((folder) => folder.repoPath)).toEqual([ + "/repos/api", + "/repos/web", + ]); + + const ui = await renderList(folders); + expect(primaryRowName(ui)).toBe("api"); + }); + + test("survive a member whose repository the host no longer serves", () => { + const [folder] = toSourceFolders(MEMBERS, []); + + expect(folder?.repoPath).toBeNull(); + expect(folder?.repoUrl).toBeNull(); + }); + + test("re-badge the primary after one is removed", () => { + const remaining = withoutFolder( + toSourceFolders(MEMBERS, REPOSITORIES), + "member-api", + ); + + expect(remaining.map((folder) => folder.folder)).toEqual(["web"]); + expect(remaining[0]?.position).toBe(0); + }); + + test("offer no folder rename, which the container has no endpoint for", async () => { + const { ui } = await renderMenu(false, false); + + expect(ui.queryByText("Rename folder")).toBeNull(); + expect(ui.getByText("Make primary")).not.toBeNull(); + expect(ui.getByText("Remove")).not.toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx index 3f47bf720f6..f7e9346c059 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx @@ -6,17 +6,23 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { LuPlus } from "react-icons/lu"; import { RemotePathPicker } from "renderer/components/RemotePathPicker"; +import { HOST_PROJECT_GROUPS_QUERY_PREFIX } from "renderer/hooks/host-projects/useHostProjectGroups"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { SettingsSection } from "../../../../../../components/SettingsSection"; import { RemoveFolderDialog } from "./components/RemoveFolderDialog"; import { RenameFolderDialog } from "./components/RenameFolderDialog"; import { SourceFolderRow } from "./components/SourceFolderRow"; -import { withoutFolder, withPrimaryFolder } from "./SourceFoldersSection.utils"; +import { + toSourceFolders, + withoutFolder, + withPrimaryFolder, +} from "./SourceFoldersSection.utils"; import type { ProjectFolder } from "./types"; interface SourceFoldersSectionProps { projectId: string; + groupId: string | null; hostUrl: string | null; hostName: string; isRemoteTarget: boolean; @@ -26,6 +32,7 @@ interface SourceFoldersSectionProps { export function SourceFoldersSection({ projectId, + groupId, hostUrl, hostName, isRemoteTarget, @@ -38,19 +45,36 @@ export function SourceFoldersSection({ const [removeTarget, setRemoveTarget] = useState(null); const [browseOpen, setBrowseOpen] = useState(false); - const queryKey = ["project-folders", "list", hostUrl, projectId]; + const queryKey = groupId + ? ["project-group-folders", "list", hostUrl, groupId] + : ["project-folders", "list", hostUrl, projectId]; const foldersQuery = useQuery({ queryKey, enabled: Boolean(hostUrl) && isProjectSetup, queryFn: async () => { if (!hostUrl) return { folders: [] as ProjectFolder[] }; - return getHostServiceClientByUrl(hostUrl).project.folders.list.query({ - projectId, - }); + const host = getHostServiceClientByUrl(hostUrl); + if (!groupId) { + return host.project.folders.list.query({ projectId }); + } + const [{ group }, repositories] = await Promise.all([ + host.projectGroups.get.query({ groupId }), + host.project.list.query(), + ]); + return { folders: toSourceFolders(group.members, repositories) }; }, }); const folders = foldersQuery.data?.folders ?? []; + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey }); + if (groupId) { + void queryClient.invalidateQueries({ + queryKey: HOST_PROJECT_GROUPS_QUERY_PREFIX, + }); + } + }; + const runOptimistically = async ( next: ProjectFolder[], request: () => Promise, @@ -64,7 +88,7 @@ export function SourceFoldersSection({ queryClient.setQueryData(queryKey, previous); throw error; } finally { - void queryClient.invalidateQueries({ queryKey }); + invalidate(); } }; @@ -76,10 +100,15 @@ export function SourceFoldersSection({ const setPrimary = useMutation({ mutationFn: (folder: ProjectFolder) => runOptimistically(withPrimaryFolder(folders, folder.id), () => - client().project.folders.setPrimary.mutate({ - projectId, - folderId: folder.id, - }), + groupId + ? client().projectGroups.setPrimary.mutate({ + groupId, + memberId: folder.id, + }) + : client().project.folders.setPrimary.mutate({ + projectId, + folderId: folder.id, + }), ), onError: (error) => toast.error(errorMessage(error)), }); @@ -87,10 +116,15 @@ export function SourceFoldersSection({ const remove = useMutation({ mutationFn: (folder: ProjectFolder) => runOptimistically(withoutFolder(folders, folder.id), () => - client().project.folders.remove.mutate({ - projectId, - folderId: folder.id, - }), + groupId + ? client().projectGroups.removeMember.mutate({ + groupId, + memberId: folder.id, + }) + : client().project.folders.remove.mutate({ + projectId, + folderId: folder.id, + }), ), onSuccess: () => setRemoveTarget(null), onError: (error) => toast.error(errorMessage(error)), @@ -116,13 +150,38 @@ export function SourceFoldersSection({ }); const add = useMutation({ - mutationFn: (repoPath: string) => - client().project.folders.add.mutate({ projectId, repoPath }), - onSuccess: (result) => { - toast.success( - t({ message: `Added ${result.folder.folder} to this project` }), + mutationFn: async (repoPath: string): Promise<{ folder: string }> => { + const host = client(); + if (!groupId) { + const result = await host.project.folders.add.mutate({ + projectId, + repoPath, + }); + return { folder: result.folder.folder }; + } + const repositories = await host.project.list.query(); + const existing = repositories.find( + (repository) => repository.repoPath === repoPath, ); - void queryClient.invalidateQueries({ queryKey }); + const repositoryName = + repoPath.split(/[\\/]/).filter(Boolean).at(-1) ?? repoPath; + const memberProjectId = + existing?.id ?? + ( + await host.project.create.mutate({ + name: repositoryName, + mode: { kind: "importLocal", repoPath }, + }) + ).projectId; + const { group } = await host.projectGroups.addMember.mutate({ + groupId, + projectId: memberProjectId, + }); + return { folder: group.members.at(-1)?.folder ?? repoPath }; + }, + onSuccess: (result) => { + toast.success(t({ message: `Added ${result.folder} to this project` })); + invalidate(); }, onError: (error) => toast.error(errorMessage(error)), }); @@ -165,7 +224,7 @@ export function SourceFoldersSection({ isPrimary={folder.position === 0} disabled={isBusy} onMakePrimary={() => setPrimary.mutate(folder)} - onRename={() => setRenameTarget(folder)} + onRename={groupId ? undefined : () => setRenameTarget(folder)} onRemove={() => setRemoveTarget(folder)} /> ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.utils.ts index 9a90687f977..93da5d5d89a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.utils.ts @@ -21,3 +21,40 @@ export function withoutFolder( .filter((folder) => folder.id !== folderId) .map((folder, position) => ({ ...folder, position })); } + +export interface SourceFolderMemberInput { + id: string; + projectId: string; + position: number; + folder: string; + baseBranch: string | null; +} + +export interface SourceFolderRepositoryInput { + id: string; + repoPath: string; + repoUrl: string | null; +} + +export function toSourceFolders( + members: SourceFolderMemberInput[], + repositories: SourceFolderRepositoryInput[], +): ProjectFolder[] { + const repositoryById = new Map( + repositories.map((repository) => [repository.id, repository]), + ); + return [...members] + .sort((left, right) => left.position - right.position) + .map((member) => { + const repository = repositoryById.get(member.projectId); + return { + id: member.id, + projectId: member.projectId, + position: member.position, + folder: member.folder, + repoPath: repository?.repoPath ?? null, + repoUrl: repository?.repoUrl ?? null, + baseBranch: member.baseBranch, + }; + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/SourceFolderRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/SourceFolderRow.tsx index 52ef46be19c..dd2169eef15 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/SourceFolderRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/SourceFolderRow.tsx @@ -16,7 +16,7 @@ interface SourceFolderRowProps { isPrimary: boolean; disabled?: boolean; onMakePrimary: () => void; - onRename: () => void; + onRename?: () => void; onRemove: () => void; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/SourceFolderMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/SourceFolderMenuItems.tsx index b7ab0a0b2df..b3ad9f01546 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/SourceFolderMenuItems.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/components/SourceFolderRow/components/SourceFolderMenuItems/SourceFolderMenuItems.tsx @@ -8,7 +8,7 @@ interface SourceFolderMenuItemsProps { isPrimary: boolean; disabled?: boolean; onMakePrimary: () => void; - onRename: () => void; + onRename?: () => void; onRemove: () => void; } @@ -33,16 +33,18 @@ export function SourceFolderMenuItems({ )} - - - - Rename folder - - + {onRename && ( + + + + Rename folder + + + )} {isPrimary ? ( From 79d1e0ef3b346dac957147fc8764b38fb35fd3ff Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 01:37:07 +0530 Subject: [PATCH 06/14] feat(desktop): name a multi-folder project by its container everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project container was only applied in the sidebar, so every other project chooser still listed repositories: a Project over two folders appeared as its primary repo's name, and its other folders appeared as projects of their own. The overlay moves out of the sidebar hook into `useGroupedProjects`, and the new-workspace composer, the recent-project list behind the tasks and automations pickers, and the sidebar all read through it. A repository adopted as another Project's first source folder is the primary of that Project *and* of its own backfilled one — `project.create` reuses the row for an already-imported path — so the group that owns several folders now wins the tie. Picking the older backfilled group rendered the wrong name and, host-side, checked out one folder instead of all of them. The picker's items key on project id so same-named projects stay individually selectable, with a filter that scores only the name: ids in the searchable value made every hex-ish query subsequence-match a UUID. --- .../useGroupedProjects/applyProjectGroups.ts} | 30 ++++++++++---- .../host-projects/useGroupedProjects/index.ts | 6 +++ .../useGroupedProjects/useGroupedProjects.ts | 41 +++++++++++++++++++ .../useHostProjectGroups.utils.test.ts | 6 +-- .../useHostProjectGroups.utils.ts | 11 ++++- .../useRecentProjects/useRecentProjects.ts | 5 ++- ...s.test.ts => sidebarProjectGroups.test.ts} | 10 ++--- .../useDashboardSidebarData.ts | 21 +--------- .../ProjectPickerPill/ProjectPickerPill.tsx | 17 +++++++- .../NewWorkspaceScreen/NewWorkspaceScreen.tsx | 11 ++++- 10 files changed, 118 insertions(+), 40 deletions(-) rename apps/desktop/src/renderer/{routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.ts => hooks/host-projects/useGroupedProjects/applyProjectGroups.ts} (50%) create mode 100644 apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/index.ts create mode 100644 apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/useGroupedProjects.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/{applyProjectGroupsToSidebarProjects.test.ts => sidebarProjectGroups.test.ts} (91%) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.ts b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/applyProjectGroups.ts similarity index 50% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.ts rename to apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/applyProjectGroups.ts index 1627ed714a0..36936f1c508 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/applyProjectGroups.ts @@ -4,21 +4,37 @@ import { indexProjectGroupsByPrimaryProjectId, } from "renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils"; -export type GroupedSidebarProject = Project & { +export type GroupedProject = Project & { groupId: string | null; repoCount: number; }; -export function applyProjectGroupsToSidebarProjects< - Project extends { id: string; name: string }, ->( - projects: Project[], +/** + * The id a grouped list renders a project under: a source folder resolves to + * the Project that owns it, since `applyProjectGroups` drops the folder's own + * row. + */ +export function resolvePrimaryProjectId( groups: HostProjectGroup[], -): GroupedSidebarProject[] { + projectId: string, +): string { + if (indexProjectGroupsByPrimaryProjectId(groups).has(projectId)) { + return projectId; + } + const owner = groups.find((group) => + group.members.some((member) => member.projectId === projectId), + ); + const primary = owner?.members.find((member) => member.position === 0); + return primary?.projectId ?? projectId; +} + +export function applyProjectGroups< + Project extends { id: string; name: string }, +>(projects: Project[], groups: HostProjectGroup[]): GroupedProject[] { const groupByPrimaryProjectId = indexProjectGroupsByPrimaryProjectId(groups); const sourceFolderOnlyProjectIds = collectSourceFolderOnlyProjectIds(groups); - return projects.flatMap((project): GroupedSidebarProject[] => { + return projects.flatMap((project): GroupedProject[] => { if (sourceFolderOnlyProjectIds.has(project.id)) return []; const group = groupByPrimaryProjectId.get(project.id); if (!group) return [{ ...project, groupId: null, repoCount: 1 }]; diff --git a/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/index.ts b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/index.ts new file mode 100644 index 00000000000..ec6cc963fed --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/index.ts @@ -0,0 +1,6 @@ +export { + applyProjectGroups, + type GroupedProject, + resolvePrimaryProjectId, +} from "./applyProjectGroups"; +export { useGroupedProjectId, useGroupedProjects } from "./useGroupedProjects"; diff --git a/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/useGroupedProjects.ts b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/useGroupedProjects.ts new file mode 100644 index 00000000000..b7da85117b2 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/useGroupedProjects.ts @@ -0,0 +1,41 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { useFeatureFlagEnabled } from "posthog-js/react"; +import { useMemo } from "react"; +import { useHostProjectGroups } from "renderer/hooks/host-projects/useHostProjectGroups"; +import { + applyProjectGroups, + type GroupedProject, + resolvePrimaryProjectId, +} from "./applyProjectGroups"; + +export function useGroupedProjects< + Project extends { id: string; name: string }, +>(projects: Project[]): GroupedProject[] { + const isMultiRepoEnabled = + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? + import.meta.env.DEV; + const { groups } = useHostProjectGroups({ enabled: isMultiRepoEnabled }); + + return useMemo( + () => applyProjectGroups(projects, isMultiRepoEnabled ? groups : []), + [groups, isMultiRepoEnabled, projects], + ); +} + +/** The id `useGroupedProjects` renders a project under — see `resolvePrimaryProjectId`. */ +export function useGroupedProjectId( + projectId: Id, +): Id | string { + const isMultiRepoEnabled = + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? + import.meta.env.DEV; + const { groups } = useHostProjectGroups({ enabled: isMultiRepoEnabled }); + + return useMemo( + () => + projectId && isMultiRepoEnabled + ? resolvePrimaryProjectId(groups, projectId) + : projectId, + [groups, isMultiRepoEnabled, projectId], + ); +} diff --git a/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.test.ts b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.test.ts index 064e5b62b8b..97285a4150e 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.test.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.test.ts @@ -37,14 +37,14 @@ const MULTI = makeGroup("group-platform", "Platform", [ ]); describe("the project a repository is the primary of", () => { - test("is the one the surfaces render it as", () => { + test("is the one that owns several source folders", () => { const index = indexProjectGroupsByPrimaryProjectId([BACKFILLED, MULTI]); - expect(index.get("project-api")).toBe(BACKFILLED); + expect(index.get("project-api")).toBe(MULTI); expect(index.get("project-web")).toBeUndefined(); }); - test("is the first group listed when two claim the same primary", () => { + test("is the first group listed when two claim it with equal standing", () => { const other = makeGroup("group-other", "Other", ["project-api"]); const index = indexProjectGroupsByPrimaryProjectId([BACKFILLED, other]); diff --git a/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.ts b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.ts index da289356a27..1e6c13f91ac 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils.ts @@ -42,13 +42,22 @@ export function findPrimaryMember( return group.members.find((member) => member.position === 0) ?? null; } +/** + * A repository adopted as another Project's first source folder is the + * primary of both that Project and its own backfilled one, so the Project + * that owns several folders wins — otherwise the container it names would + * never be the one that renders or gets checked out. Host-side + * `findGroupForPrimaryProject` resolves the same way. + */ export function indexProjectGroupsByPrimaryProjectId( groups: HostProjectGroup[], ): Map { const byProjectId = new Map(); for (const group of groups) { const primary = findPrimaryMember(group); - if (!primary || byProjectId.has(primary.projectId)) continue; + if (!primary) continue; + const claimed = byProjectId.get(primary.projectId); + if (claimed && claimed.members.length >= group.members.length) continue; byProjectId.set(primary.projectId, group); } return byProjectId; diff --git a/apps/desktop/src/renderer/hooks/host-projects/useRecentProjects/useRecentProjects.ts b/apps/desktop/src/renderer/hooks/host-projects/useRecentProjects/useRecentProjects.ts index 1f5fea0b226..8250f3493ae 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/useRecentProjects/useRecentProjects.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/useRecentProjects/useRecentProjects.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { resolveProjectIconUrl } from "renderer/hooks/host-projects/resolveProjectIconUrl"; +import { useGroupedProjects } from "renderer/hooks/host-projects/useGroupedProjects"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import type { ProjectOption } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types"; @@ -8,7 +9,7 @@ export function useRecentProjects(): ProjectOption[] { // includes local-first projects (the frozen cloud collection never will). const { projects: hostProjects } = useHostProjects(); - return useMemo( + const repositoryProjects = useMemo( () => hostProjects.map((project) => ({ id: project.projectKey, @@ -20,4 +21,6 @@ export function useRecentProjects(): ProjectOption[] { })), [hostProjects], ); + + return useGroupedProjects(repositoryProjects); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/sidebarProjectGroups.test.ts similarity index 91% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/sidebarProjectGroups.test.ts index 7eded528c04..24ea55992f9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/applyProjectGroupsToSidebarProjects.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/sidebarProjectGroups.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; +import { applyProjectGroups } from "renderer/hooks/host-projects/useGroupedProjects"; import type { HostProjectGroup } from "renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils"; -import { applyProjectGroupsToSidebarProjects } from "./applyProjectGroupsToSidebarProjects"; import { buildDashboardSidebarProjects, type SidebarProjectInput, @@ -89,7 +89,7 @@ describe("a project backfilled from a single repository", () => { const projects = [makeProject()]; const flagOff = build(projects); const flagOn = build( - applyProjectGroupsToSidebarProjects(projects, [ + applyProjectGroups(projects, [ makeGroup("group-api", "api", ["project-api"]), ]), ); @@ -102,7 +102,7 @@ describe("a project backfilled from a single repository", () => { }); it("keeps rendering when no host has grouped it yet", () => { - const grouped = applyProjectGroupsToSidebarProjects([makeProject()], []); + const grouped = applyProjectGroups([makeProject()], []); expect(grouped).toHaveLength(1); expect(grouped[0]).toMatchObject({ @@ -116,7 +116,7 @@ describe("a project backfilled from a single repository", () => { describe("a project over several repositories", () => { it("renders once, under its own name, counting its source folders", () => { - const grouped = applyProjectGroupsToSidebarProjects( + const grouped = applyProjectGroups( [makeProject(), makeProject({ id: "project-web", name: "web" })], [makeGroup("group-platform", "Platform", ["project-api", "project-web"])], ); @@ -128,7 +128,7 @@ describe("a project over several repositories", () => { }); it("still renders a source folder that is a project in its own right", () => { - const grouped = applyProjectGroupsToSidebarProjects( + const grouped = applyProjectGroups( [makeProject(), makeProject({ id: "project-web", name: "web" })], [ makeGroup("group-platform", "Platform", ["project-api", "project-web"]), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index ef3288e1b93..a545de9c3f6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -1,10 +1,8 @@ -import { FEATURE_FLAGS } from "@superset/shared/constants"; import { useLiveQuery } from "@tanstack/react-db"; import { useQueries, useQueryClient } from "@tanstack/react-query"; -import { useFeatureFlagEnabled } from "posthog-js/react"; import { useCallback, useMemo, useRef } from "react"; import { resolveProjectIconUrl } from "renderer/hooks/host-projects/resolveProjectIconUrl"; -import { useHostProjectGroups } from "renderer/hooks/host-projects/useHostProjectGroups"; +import { useGroupedProjects } from "renderer/hooks/host-projects/useGroupedProjects"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useKnownHosts } from "renderer/hooks/known-hosts/useKnownHosts"; import { useRelayUrl } from "renderer/hooks/useRelayUrl"; @@ -25,7 +23,6 @@ import type { DashboardSidebarProject, DashboardSidebarWorkspace, } from "../../types"; -import { applyProjectGroupsToSidebarProjects } from "./applyProjectGroupsToSidebarProjects"; import { buildDashboardSidebarPinnedWorkspaces, buildDashboardSidebarProjects, @@ -242,21 +239,7 @@ export function useDashboardSidebarData() { }), [orderedSidebarProjectRows, hostProjectsByKey], ); - const isMultiRepoEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? false; - const { groups: hostProjectGroups } = useHostProjectGroups({ - enabled: isMultiRepoEnabled, - }); - const sidebarProjects = useMemo( - () => - isMultiRepoEnabled - ? applyProjectGroupsToSidebarProjects( - repositorySidebarProjects, - hostProjectGroups, - ) - : repositorySidebarProjects, - [hostProjectGroups, isMultiRepoEnabled, repositorySidebarProjects], - ); + const sidebarProjects = useGroupedProjects(repositorySidebarProjects); const hiddenProjects = useMemo( () => orderedSidebarProjectRows.flatMap((row) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx index 3b802a6ebcd..d991cbf17cc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx @@ -134,7 +134,18 @@ export function ProjectPickerPill({ className="w-60 p-0" onWheel={(event) => event.stopPropagation()} > - + + keywords?.some((keyword) => + keyword.toLowerCase().includes(search.toLowerCase()), + ) + ? 1 + : 0 + } + > { onSelectProject(null); setOpen(false); @@ -164,7 +176,8 @@ export function ProjectPickerPill({ {projects.map((project) => ( { onSelectProject(project.id); setOpen(false); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/NewWorkspaceScreen.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/NewWorkspaceScreen.tsx index 83bf12c6ef2..5ec5de5d655 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/NewWorkspaceScreen.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/NewWorkspaceScreen.tsx @@ -37,6 +37,10 @@ import { GitHubStarPill } from "renderer/components/GitHubStarPill"; import { IssueLinkCommand } from "renderer/components/IssueLinkCommand"; import { LinkedIssuePill } from "renderer/components/LinkedIssuePill"; import { MarkdownEditor } from "renderer/components/MarkdownEditor"; +import { + useGroupedProjectId, + useGroupedProjects, +} from "renderer/hooks/host-projects/useGroupedProjects"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { resolveHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useActiveOrganizationId } from "renderer/hooks/useActiveOrganizationId"; @@ -254,7 +258,7 @@ export function NewWorkspaceScreen({ const { projects: hostProjects, isReady: areProjectsReady } = useHostProjects(); const setUpProjectIds = useSelectedHostProjectIds(draft.hostId); - const projects = useMemo( + const repositoryProjects = useMemo( () => hostProjects .filter((project) => Boolean(project.projectKey)) @@ -273,12 +277,15 @@ export function NewWorkspaceScreen({ })), [hostProjects, setUpProjectIds], ); + const projects = useGroupedProjects(repositoryProjects); + // A preselection naming a source folder has no row of its own to match. + const groupedPreSelectedProjectId = useGroupedProjectId(preSelectedProjectId); const isProjectPreselectionPending = useProjectPreselection({ isOpen, areProjectsReady, projects, - preSelectedProjectId, + preSelectedProjectId: groupedPreSelectedProjectId, preSelectedSession, selectedProjectId: draft.selectedProjectId, isSession: draft.isSession, From 70e9baf4170af112d6e5db266ba954080b69f32f Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 01:37:15 +0530 Subject: [PATCH 07/14] feat(desktop): give a project over several folders its own settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings listed repositories, so a Project appeared under its primary repo's name and its other folders sat beside it as peers — the page you landed on mixed the container's settings (name, source folders) with one repository's (location, sparse checkout, lifecycle scripts). The list now shows one row per Project with its folders indented beneath, and the two kinds of settings split: `/settings/projects/group/$groupId` owns the name, icon, source folders, and the settings that describe the whole checkout — branch prefix, naming instructions, the container's base directory — while a folder row opens the repository page it always was, headed by a link back to the Project it belongs to. A Project over one folder still renders as the single row it did before, and its page keeps everything. Deleting a Project removes the container only; `ON DELETE restrict` on the membership rows means its repositories could not have followed it anyway. Adding a second folder from a single-folder page lands on the Project page that now owns those settings, rather than watching them unmount in place. --- .../ProjectsSettingsSidebar.tsx | 106 +++++--- .../buildProjectSettingsRows.test.ts | 172 ++++++++++++ .../buildProjectSettingsRows.ts | 75 ++++++ .../ProjectGroupSettings.tsx | 255 ++++++++++++++++++ .../DeleteProjectGroupSection.tsx | 107 ++++++++ .../DeleteProjectGroupSection/index.ts | 1 + .../components/ProjectGroupSettings/index.ts | 1 + .../settings/projects/group/$groupId/page.tsx | 15 ++ .../settings/projects/layout.tsx | 14 +- .../V2ProjectSettings.test.tsx | 1 + .../V2ProjectSettings/V2ProjectSettings.tsx | 238 +++++++++------- .../SourceFoldersSection.tsx | 11 + 12 files changed, 856 insertions(+), 140 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/ProjectGroupSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/components/DeleteProjectGroupSection/DeleteProjectGroupSection.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/components/DeleteProjectGroupSection/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/page.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx index bc013995c20..e0137c7c170 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx @@ -1,7 +1,12 @@ import { useLingui } from "@lingui/react/macro"; +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { cn } from "@superset/ui/utils"; import { Link } from "@tanstack/react-router"; +import { useFeatureFlagEnabled } from "posthog-js/react"; import { useMemo } from "react"; +import { LuFolder } from "react-icons/lu"; import { resolveProjectIconUrl } from "renderer/hooks/host-projects/resolveProjectIconUrl"; +import { useHostProjectGroups } from "renderer/hooks/host-projects/useHostProjectGroups"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -12,21 +17,23 @@ import { SettingsListSidebar, settingsListItemClass, } from "../../../components/SettingsListSidebar"; +import { + buildProjectSettingsRows, + type ProjectSettingsRow, +} from "./buildProjectSettingsRows"; -interface ProjectRow { - kind: "v1" | "v2"; - id: string; - name: string; - iconUrl: string | null; - color: string | null; -} +type ProjectRow = Omit & { + kind: "v1" | "project" | "folder"; +}; interface ProjectsSettingsSidebarProps { selectedProjectId: string | null; + selectedGroupId: string | null; } export function ProjectsSettingsSidebar({ selectedProjectId, + selectedGroupId, }: ProjectsSettingsSidebarProps) { const { t } = useLingui(); const isV2CloudEnabled = useIsV2CloudEnabled(); @@ -35,6 +42,12 @@ export function ProjectsSettingsSidebar({ // Projects are fully local — identity comes from the host fan-out. const { projects: hostProjects } = useHostProjects(); + const isMultiRepoEnabled = + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? + import.meta.env.DEV; + const { groups: projectGroups } = useHostProjectGroups({ + enabled: isMultiRepoEnabled, + }); const v2Projects = useMemo( () => hostProjects.map((project) => ({ @@ -48,13 +61,10 @@ export function ProjectsSettingsSidebar({ const listGroups = useMemo>>(() => { if (isV2CloudEnabled) { - const v2Rows: ProjectRow[] = v2Projects.map((p) => ({ - kind: "v2", - id: p.id, - name: p.name, - iconUrl: p.iconUrl ?? null, - color: p.color, - })); + const v2Rows: ProjectRow[] = buildProjectSettingsRows( + v2Projects, + isMultiRepoEnabled ? projectGroups : [], + ); return [{ id: "v2", title: "v2", rows: v2Rows }]; } @@ -64,9 +74,12 @@ export function ProjectsSettingsSidebar({ name: g.project.name, iconUrl: g.project.iconUrl, color: g.project.color === PROJECT_COLOR_DEFAULT ? null : g.project.color, + groupId: null, + parentGroupId: null, + depth: 0, })); return [{ id: "v1", title: "v1", rows: v1Rows }]; - }, [groups, v2Projects, isV2CloudEnabled]); + }, [groups, isMultiRepoEnabled, isV2CloudEnabled, projectGroups, v2Projects]); return ( row.name.toLowerCase().includes(q.toLowerCase())} - getRowKey={(row) => `${row.kind}:${row.id}`} + getRowKey={(row) => + `${row.kind}:${row.groupId ?? row.parentGroupId ?? ""}:${row.id}` + } emptyLabel={t({ message: "No projects yet.", })} @@ -88,24 +103,47 @@ export function ProjectsSettingsSidebar({ message: `No projects match "${q}".`, }) } - renderRow={(row) => ( - - - {row.name} - - )} + renderRow={(row) => + row.groupId ? ( + + + {row.name} + + ) : ( + + {row.depth === 1 ? ( + + ) : ( + + )} + {row.name} + + ) + } /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.test.ts new file mode 100644 index 00000000000..3c9bd77ca4d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; +import type { HostProjectGroup } from "renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils"; +import { + buildProjectSettingsRows, + type ProjectSettingsRowInput, +} from "./buildProjectSettingsRows"; + +function makeProject(id: string, name: string): ProjectSettingsRowInput { + return { id, name, iconUrl: null, color: null }; +} + +function makeGroup( + id: string, + name: string, + members: Array<{ projectId: string; folder: string }>, +): HostProjectGroup { + return { + id, + hostId: "machine-1", + name, + icon: null, + color: null, + createdAt: 1, + updatedAt: 1, + members: members.map((member, position) => ({ + id: `${id}-m${position}`, + groupId: id, + projectId: member.projectId, + position, + folder: member.folder, + baseBranch: null, + })), + }; +} + +describe("a project over several source folders", () => { + it("lists the project once, with its folders indented under it", () => { + const rows = buildProjectSettingsRows( + [ + makeProject("project-town", "town"), + makeProject("project-roster", "roster"), + ], + [ + makeGroup("group-samplee", "samplee", [ + { projectId: "project-town", folder: "town" }, + { projectId: "project-roster", folder: "roster" }, + ]), + ], + ); + + expect(rows).toEqual([ + { + id: "project-town", + name: "samplee", + iconUrl: null, + color: null, + kind: "project", + groupId: "group-samplee", + parentGroupId: null, + depth: 0, + }, + { + id: "project-town", + name: "town", + iconUrl: null, + color: null, + kind: "folder", + groupId: null, + parentGroupId: "group-samplee", + depth: 1, + }, + { + id: "project-roster", + name: "roster", + iconUrl: null, + color: null, + kind: "folder", + groupId: null, + parentGroupId: "group-samplee", + depth: 1, + }, + ]); + }); + + it("still lists a source folder that is a project in its own right", () => { + const rows = buildProjectSettingsRows( + [ + makeProject("project-town", "town"), + makeProject("project-roster", "roster"), + ], + [ + makeGroup("group-samplee", "samplee", [ + { projectId: "project-town", folder: "town" }, + { projectId: "project-roster", folder: "roster" }, + ]), + makeGroup("group-roster", "roster", [ + { projectId: "project-roster", folder: "roster" }, + ]), + ], + ); + + expect( + rows + .filter((row) => row.depth === 0) + .map((row) => [row.name, row.groupId]), + ).toEqual([ + ["samplee", "group-samplee"], + ["roster", null], + ]); + }); +}); + +describe("a project over one source folder", () => { + it("renders the single row it rendered before it had a container", () => { + const rows = buildProjectSettingsRows( + [makeProject("project-api", "api")], + [ + makeGroup("group-api", "api", [ + { projectId: "project-api", folder: "api" }, + ]), + ], + ); + + expect(rows).toEqual([ + { + id: "project-api", + name: "api", + iconUrl: null, + color: null, + kind: "project", + groupId: null, + parentGroupId: null, + depth: 0, + }, + ]); + }); + + it("renders a project no host has grouped yet", () => { + const rows = buildProjectSettingsRows( + [makeProject("project-api", "api")], + [], + ); + + expect(rows.map((row) => row.name)).toEqual(["api"]); + }); +}); + +describe("a repository that is the first folder of another project", () => { + it("renders under the project that owns several folders, not its own", () => { + const rows = buildProjectSettingsRows( + [ + makeProject("project-roster", "roster"), + makeProject("project-town", "town"), + ], + [ + makeGroup("group-roster", "roster", [ + { projectId: "project-roster", folder: "roster" }, + ]), + makeGroup("group-samplee", "samplee", [ + { projectId: "project-roster", folder: "roster" }, + { projectId: "project-town", folder: "town" }, + ]), + ], + ); + + expect(rows.map((row) => [row.name, row.depth])).toEqual([ + ["samplee", 0], + ["roster", 1], + ["town", 1], + ]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.ts new file mode 100644 index 00000000000..ae31de19034 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.ts @@ -0,0 +1,75 @@ +import { + collectSourceFolderOnlyProjectIds, + type HostProjectGroup, + indexProjectGroupsByPrimaryProjectId, +} from "renderer/hooks/host-projects/useHostProjectGroups/useHostProjectGroups.utils"; + +export interface ProjectSettingsRowInput { + id: string; + name: string; + iconUrl: string | null; + color: string | null; +} + +export type ProjectSettingsRow = ProjectSettingsRowInput & { + kind: "project" | "folder"; + /** Set when the row opens a Project page rather than a repository page. */ + groupId: string | null; + /** The Project a folder row belongs to; a repo can be a folder of several. */ + parentGroupId: string | null; + depth: 0 | 1; +}; + +export function buildProjectSettingsRows( + projects: ProjectSettingsRowInput[], + groups: HostProjectGroup[], +): ProjectSettingsRow[] { + const groupByPrimaryProjectId = indexProjectGroupsByPrimaryProjectId(groups); + const sourceFolderOnlyProjectIds = collectSourceFolderOnlyProjectIds(groups); + const projectsById = new Map( + projects.map((project) => [project.id, project]), + ); + + return projects.flatMap((project): ProjectSettingsRow[] => { + if (sourceFolderOnlyProjectIds.has(project.id)) return []; + const group = groupByPrimaryProjectId.get(project.id); + if (!group || group.members.length < 2) { + return [ + { + ...project, + // A rename writes the group's name, so it is the one the + // dashboard sidebar and the project page already show. + name: group?.name ?? project.name, + kind: "project", + groupId: null, + parentGroupId: null, + depth: 0, + }, + ]; + } + return [ + { + ...project, + name: group.name, + kind: "project", + groupId: group.id, + parentGroupId: null, + depth: 0, + }, + ...group.members.flatMap((member): ProjectSettingsRow[] => { + const memberProject = projectsById.get(member.projectId); + if (!memberProject) return []; + return [ + { + ...memberProject, + name: member.folder, + kind: "folder", + groupId: null, + parentGroupId: group.id, + depth: 1, + }, + ]; + }), + ]; + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/ProjectGroupSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/ProjectGroupSettings.tsx new file mode 100644 index 00000000000..385c3345fd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/ProjectGroupSettings.tsx @@ -0,0 +1,255 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { LuChevronRight, LuFolder } from "react-icons/lu"; +import { + PROJECT_ICON_NONE, + resolveProjectIconUrl, +} from "renderer/hooks/host-projects/resolveProjectIconUrl"; +import { useHostProjectGroups } from "renderer/hooks/host-projects/useHostProjectGroups"; +import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { SettingsRow } from "renderer/routes/_authenticated/settings/components/SettingsRow"; +import { SettingsSection } from "renderer/routes/_authenticated/settings/components/SettingsSection"; +import { BranchPrefixSection } from "renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/BranchPrefixSection"; +import { IconUploadField } from "renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField"; +import { NameSection } from "renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NameSection"; +import { NamingInstructionsSection } from "renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/NamingInstructionsSection"; +import { SourceFoldersSection } from "renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection"; +import { WorktreeLocationSection } from "renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/WorktreeLocationSection"; +import { DeleteProjectGroupSection } from "./components/DeleteProjectGroupSection"; + +interface ProjectGroupSettingsProps { + groupId: string; +} + +export function ProjectGroupSettings({ groupId }: ProjectGroupSettingsProps) { + const { t } = useLingui(); + const { machineId } = useLocalHostService(); + const { groups, isReady: areGroupsReady } = useHostProjectGroups({ + enabled: true, + }); + const group = useMemo( + () => groups.find((candidate) => candidate.id === groupId) ?? null, + [groupId, groups], + ); + const hostUrl = useHostUrl(group?.hostId ?? null); + const hostName = + group?.hostId === machineId + ? t({ message: "this device" }) + : (group?.hostId ?? ""); + const isRemoteTarget = Boolean( + group?.hostId && machineId && group.hostId !== machineId, + ); + + const { projects: hostProjects } = useHostProjects(); + const primaryProjectId = group?.members[0]?.projectId ?? null; + const primaryProject = useMemo( + () => + hostProjects.find((project) => project.projectKey === primaryProjectId) ?? + null, + [hostProjects, primaryProjectId], + ); + const projectsById = useMemo( + () => new Map(hostProjects.map((project) => [project.projectKey, project])), + [hostProjects], + ); + + const { data: hostProject, refetch: refetchHostProject } = useQuery({ + queryKey: ["host-project", "get", hostUrl, primaryProjectId], + enabled: Boolean(hostUrl && primaryProjectId), + queryFn: async () => { + if (!hostUrl || !primaryProjectId) return null; + const client = getHostServiceClientByUrl(hostUrl); + return client.project.get.query({ projectId: primaryProjectId }); + }, + }); + + if (!group || !primaryProjectId) { + if (!areGroupsReady) return null; + return ( +
+ Project not found. +
+ ); + } + + const projectIcon = group.icon ?? hostProject?.icon ?? null; + const iconUrl = resolveProjectIconUrl({ + icon: projectIcon, + repoOwner: primaryProject?.repoOwner ?? null, + }); + + return ( +
+
+ +

{group.name}

+
+ +
+ + + + + + + + + + + + + {hostUrl && hostProject && ( + + refetchHostProject()} + /> + + )} + {hostUrl && hostProject && ( + refetchHostProject()} + /> + )} + + + + + refetchHostProject()} + /> + + + + +
+ {group.members.map((member) => ( + + + {member.folder} + + {projectsById.get(member.projectId)?.repoPath ?? ""} + + + + ))} +
+
+ + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/components/DeleteProjectGroupSection/DeleteProjectGroupSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/components/DeleteProjectGroupSection/DeleteProjectGroupSection.tsx new file mode 100644 index 00000000000..b4d8e740ebb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/components/DeleteProjectGroupSection/DeleteProjectGroupSection.tsx @@ -0,0 +1,107 @@ +import { Trans } from "@lingui/react/macro"; +import { errorMessage } from "@superset/i18n/errors"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { HOST_PROJECT_GROUPS_QUERY_PREFIX } from "renderer/hooks/host-projects/useHostProjectGroups"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; + +interface DeleteProjectGroupSectionProps { + groupId: string; + groupName: string; + hostUrl: string | null; +} + +export function DeleteProjectGroupSection({ + groupId, + groupName, + hostUrl, +}: DeleteProjectGroupSectionProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [isOpen, setIsOpen] = useState(false); + + const remove = useMutation({ + mutationFn: async () => { + if (!hostUrl) throw new Error("Host unavailable"); + await getHostServiceClientByUrl(hostUrl).projectGroups.remove.mutate({ + groupId, + }); + }, + onSuccess: async () => { + setIsOpen(false); + await queryClient.invalidateQueries({ + queryKey: HOST_PROJECT_GROUPS_QUERY_PREFIX, + }); + void navigate({ to: "/settings/projects" }); + }, + onError: (error) => toast.error(errorMessage(error)), + }); + + return ( +
+
+
+ Delete project +
+

+ + Its source folders stay on this device as projects of their own. + +

+
+ + + + + + Delete {groupName}? + + + + New workspaces will no longer check its folders out together. + Existing workspaces keep their checkouts, and no repository is + deleted from disk. + + + + + + Cancel + + { + event.preventDefault(); + remove.mutate(); + }} + disabled={remove.isPending} + > + Delete project + + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/components/DeleteProjectGroupSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/components/DeleteProjectGroupSection/index.ts new file mode 100644 index 00000000000..23c4117fea9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/components/DeleteProjectGroupSection/index.ts @@ -0,0 +1 @@ +export { DeleteProjectGroupSection } from "./DeleteProjectGroupSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/index.ts new file mode 100644 index 00000000000..bc33a0e8e52 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/index.ts @@ -0,0 +1 @@ +export { ProjectGroupSettings } from "./ProjectGroupSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/page.tsx new file mode 100644 index 00000000000..b292c48bf33 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/page.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { NotFound } from "renderer/routes/not-found"; +import { ProjectGroupSettings } from "./components/ProjectGroupSettings"; + +export const Route = createFileRoute( + "/_authenticated/settings/projects/group/$groupId/", +)({ + component: ProjectGroupDetailPage, + notFoundComponent: NotFound, +}); + +function ProjectGroupDetailPage() { + const { groupId } = Route.useParams(); + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/layout.tsx index 15db536d6e5..ac319da560b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/layout.tsx @@ -7,11 +7,19 @@ export const Route = createFileRoute("/_authenticated/settings/projects")({ }); function ProjectsSettingsLayout() { - const params = useParams({ strict: false }) as { projectId?: string }; - const contentRef = useScrollReset(params.projectId); + const params = useParams({ strict: false }) as { + projectId?: string; + groupId?: string; + }; + const contentRef = useScrollReset( + params.groupId ?? params.projectId, + ); return (
- +
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx index 6b7da1e4c2c..2b9ed2b55bf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.test.tsx @@ -57,6 +57,7 @@ mock.module("@tanstack/react-query", () => ({ })); mock.module("@tanstack/react-router", () => ({ useNavigate: () => () => {}, + Link: ({ children }: { children?: React.ReactNode }) => children, })); mock.module("renderer/hooks/host-projects/useHostProjects", () => ({ useHostProjects: () => ({ projects: [project], isReady: true }), diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index d8cb899bc36..67a011b8208 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -2,9 +2,10 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Label } from "@superset/ui/label"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useEffect, useMemo, useRef } from "react"; +import { LuFolder } from "react-icons/lu"; import { PROJECT_ICON_NONE, resolveProjectIconUrl, @@ -52,7 +53,8 @@ export function V2ProjectSettings({ const navigate = useNavigate(); const { t } = useLingui(); const isMultiRepoEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? false; + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? + import.meta.env.DEV; const { machineId } = useLocalHostService(); const { currentDeviceName, localHostId, otherHosts } = useWorkspaceHostOptions(); @@ -77,6 +79,11 @@ export function V2ProjectSettings({ ), [hostProjectGroups, projectId, targetHostId], ); + const parentProject = + projectGroup && projectGroup.members.length > 1 ? projectGroup : null; + const folder = + parentProject?.members.find((member) => member.projectId === projectId) ?? + null; const hostOptions = useMemo(() => { const options: HostSelectOption[] = []; @@ -188,14 +195,31 @@ export function V2ProjectSettings({
- -

- {projectGroup?.name ?? project.name} -

+ {parentProject ? ( + + ) : ( + + )} +
+ {parentProject && ( + + Source folder of {parentProject.name} + + )} +

+ {parentProject + ? (folder?.folder ?? project.name) + : (projectGroup?.name ?? project.name)} +

+
{hasMultipleHosts && targetHostId ? ( - - refetchHostProject()} - /> - + {!parentProject && ( + + refetchHostProject()} + /> + + )} - - - + {!parentProject && ( + + + + )} - {isMultiRepoEnabled && ( + {isMultiRepoEnabled && !parentProject && ( )} - - {targetHostUrl && hostProject && ( - - + {targetHostUrl && hostProject && ( + + refetchHostProject()} + /> + + )} + {targetHostUrl && hostProject && ( + refetchHostProject()} /> - - )} - {targetHostUrl && hostProject && ( - refetchHostProject()} - /> - )} - + )} + + )} refetchHostProject()} /> - - refetchHostProject()} - /> - + {!parentProject && ( + + refetchHostProject()} + /> + + )} {targetHostUrl && hostProject && (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx index f7e9346c059..39cebff4236 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/SourceFoldersSection/SourceFoldersSection.tsx @@ -3,6 +3,7 @@ import { errorMessage } from "@superset/i18n/errors"; import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { LuPlus } from "react-icons/lu"; import { RemotePathPicker } from "renderer/components/RemotePathPicker"; @@ -40,6 +41,7 @@ export function SourceFoldersSection({ }: SourceFoldersSectionProps) { const { t } = useLingui(); const queryClient = useQueryClient(); + const navigate = useNavigate(); const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); const [renameTarget, setRenameTarget] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); @@ -182,6 +184,15 @@ export function SourceFoldersSection({ onSuccess: (result) => { toast.success(t({ message: `Added ${result.folder} to this project` })); invalidate(); + // The second folder moves the project's settings to the Project page; + // this one is about to become a source folder's page. + if (groupId && folders.length === 1) { + void navigate({ + to: "/settings/projects/group/$groupId", + params: { groupId }, + replace: true, + }); + } }, onError: (error) => toast.error(errorMessage(error)), }); From fe4392206bb4a13ffaa8cd2c5060ee8bd71569a2 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 01:37:23 +0530 Subject: [PATCH 08/14] feat(host-service): check a project's folders out together and bootstrap each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `workspaces.create` resolved folders from `project_folders`, which for a project assembled out of several repositories holds only its own — so creating a workspace from it produced a single-repo checkout and none of the folders the settings page listed. It now appends the rest of the Project's membership, additively: a backfilled group can hold fewer members than the folder list it came from, so a member whose repository a folder row already covers is skipped rather than replacing it, and the primary checkout takes the directory name its membership row advertises. Lifecycle scripts follow the same shape. Setup and teardown resolve per checkout and chain in folder order in one shell, so each repository bootstraps by its own conventions and a failure still short-circuits the rest. A workspace whose only script is the primary's resolves to exactly the command it did before. The agent is sent back to the primary checkout after a chained setup, since the chain ends wherever the last folder ran. --- .../src/projects/project-groups.ts | 21 +++ .../src/projects/workspace-checkouts.ts | 48 ++++++ .../src/runtime/teardown/teardown.ts | 63 ++++++- .../shared/multi-repo/resolve-folders.ts | 79 ++++++++- .../shared/setup-terminal.ts | 38 ++++- .../multi-repo-lifecycle.integration.test.ts | 158 ++++++++++++++++++ .../multi-repo-workspace.integration.test.ts | 121 ++++++++++++++ 7 files changed, 513 insertions(+), 15 deletions(-) create mode 100644 packages/host-service/src/projects/workspace-checkouts.ts create mode 100644 packages/host-service/test/integration/multi-repo-lifecycle.integration.test.ts diff --git a/packages/host-service/src/projects/project-groups.ts b/packages/host-service/src/projects/project-groups.ts index ee989cd43d5..f47f2977b4f 100644 --- a/packages/host-service/src/projects/project-groups.ts +++ b/packages/host-service/src/projects/project-groups.ts @@ -96,6 +96,27 @@ export function listProjectGroups(db: HostDb): ProjectGroup[] { .sort((a, b) => a.createdAt - b.createdAt); } +/** + * A repository adopted as another project's first source folder is the + * primary of both that group and its own backfilled one, so the group that + * owns several folders wins — the renderer's + * `indexProjectGroupsByPrimaryProjectId` resolves the same way, so the name + * shown is the name whose folders get checked out. + */ +export function findGroupForPrimaryProject( + db: HostDb, + projectId: string, +): ProjectGroup | null { + let claimed: ProjectGroup | null = null; + for (const group of listProjectGroups(db)) { + const primary = group.members.find((member) => member.position === 0); + if (primary?.projectId !== projectId) continue; + if (claimed && claimed.members.length >= group.members.length) continue; + claimed = group; + } + return claimed; +} + export function reassignMemberPositions( db: HostDb, orderedMemberIds: string[], diff --git a/packages/host-service/src/projects/workspace-checkouts.ts b/packages/host-service/src/projects/workspace-checkouts.ts new file mode 100644 index 00000000000..8171adfcb61 --- /dev/null +++ b/packages/host-service/src/projects/workspace-checkouts.ts @@ -0,0 +1,48 @@ +import { eq } from "drizzle-orm"; +import type { HostDb } from "../db"; +import { projects, workspaceRepos } from "../db/schema"; + +export interface WorkspaceCheckout { + projectId: string; + repoPath: string; + worktreePath: string; + folder: string; +} + +export function listWorkspaceCheckouts( + db: HostDb, + workspaceId: string, + primary: { projectId: string; repoPath: string; worktreePath: string }, +): WorkspaceCheckout[] { + const rows = db + .select({ + projectId: workspaceRepos.projectId, + worktreePath: workspaceRepos.worktreePath, + folder: workspaceRepos.folder, + position: workspaceRepos.position, + repoPath: projects.repoPath, + }) + .from(workspaceRepos) + .innerJoin(projects, eq(projects.id, workspaceRepos.projectId)) + .where(eq(workspaceRepos.workspaceId, workspaceId)) + .all() + .sort((left, right) => left.position - right.position); + + if (rows.length === 0) { + return [ + { + projectId: primary.projectId, + repoPath: primary.repoPath, + worktreePath: primary.worktreePath, + folder: "", + }, + ]; + } + + return rows.map((row) => ({ + projectId: row.projectId, + repoPath: row.repoPath, + worktreePath: row.worktreePath, + folder: row.folder, + })); +} diff --git a/packages/host-service/src/runtime/teardown/teardown.ts b/packages/host-service/src/runtime/teardown/teardown.ts index 867b77d91a7..428bd9e0d05 100644 --- a/packages/host-service/src/runtime/teardown/teardown.ts +++ b/packages/host-service/src/runtime/teardown/teardown.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { TEARDOWN_TIMEOUT_MS } from "@superset/shared/constants"; import type { HostDb } from "../../db"; +import { listWorkspaceCheckouts } from "../../projects/workspace-checkouts"; import { createTerminalSessionInternal, disposeSession, @@ -60,7 +61,9 @@ export async function runTeardown({ timeoutMs = TEARDOWN_TIMEOUT_MS, homeDir, }: RunTeardownOptions): Promise { - const resolved = resolveTeardownCommand({ + const resolved = resolveWorkspaceTeardown({ + db, + workspaceId, repoPath, projectId, worktreePath, @@ -183,6 +186,64 @@ export function resolveTeardownCommand(args: { return { initialCommand, ...(resolved.cwd && { cwd: resolved.cwd }) }; } +/** + * The workspace's teardown across every checkout it holds: each folder's own + * command, in folder order, in one shell. A single-repo workspace resolves to + * exactly what {@link resolveTeardownCommand} returned before. + */ +export function resolveWorkspaceTeardown(args: { + db: HostDb; + workspaceId: string; + repoPath: string; + projectId: string; + worktreePath: string; + homeDir?: string; +}): { initialCommand: string; cwd?: string } | null { + const checkouts = listWorkspaceCheckouts(args.db, args.workspaceId, { + projectId: args.projectId, + repoPath: args.repoPath, + worktreePath: args.worktreePath, + }); + const resolved = checkouts.flatMap((checkout) => { + const script = resolveScript("teardown", { + repoPath: checkout.repoPath, + projectId: checkout.projectId, + worktreePath: checkout.worktreePath, + ...(args.homeDir && { homeDir: args.homeDir }), + }); + return script ? [{ ...checkout, ...script }] : []; + }); + if (resolved.length === 0) return null; + + const first = resolved[0]; + if ( + resolved.length === 1 && + first && + first.worktreePath === args.worktreePath + ) { + return { + initialCommand: + first.kind === "commands" + ? buildTeardownCommandFromShell(first.commands.join(" && ")) + : buildTeardownInitialCommand(first.scriptPath), + ...(first.cwd && { cwd: first.cwd }), + }; + } + + const shellCommand = resolved + .map((script) => { + const enter = [`cd ${shellSingleQuote(script.worktreePath)}`]; + if (script.cwd) enter.push(`cd ${shellSingleQuote(script.cwd)}`); + const command = + script.kind === "commands" + ? script.commands.join(" && ") + : `bash ${shellSingleQuote(script.scriptPath)}`; + return [...enter, command].join(" && "); + }) + .join(" && "); + return { initialCommand: buildTeardownCommandFromShell(shellCommand) }; +} + export function buildTeardownInitialCommand(scriptPath: string): string { // `exec` replaces the user's login shell with the teardown process. That // avoids shell-specific exit-status syntax like `$?`, which breaks in fish diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/resolve-folders.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/resolve-folders.ts index bc98c2d3c73..0b89467c04f 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/resolve-folders.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/multi-repo/resolve-folders.ts @@ -11,7 +11,12 @@ import { defaultFolderNameForRepo, listProjectFolders, type ProjectFolder, + sanitizeFolderName, } from "../../../../../projects/project-folders"; +import { + findGroupForPrimaryProject, + type ProjectGroup, +} from "../../../../../projects/project-groups"; import type { HostServiceContext } from "../../../../../types"; import { persistLocalProject } from "../../../project/utils/persist-project"; import { @@ -35,22 +40,34 @@ export interface ResolvedFolder { export type ProgressReporter = (line: string) => void; +type EffectiveFolder = ProjectFolder & { linkedProjectId: string | null }; + /** - * The project's own folders plus one per extra project id. Extra ids never - * mutate the project's folder list — they shape this workspace only. + * The project's own folders, the source folders of the Project it is the + * primary of, then one per extra project id. Neither the group membership + * nor the extra ids mutate the project's folder list. */ export function effectiveProjectFolders( ctx: HostServiceContext, projectId: string, extraProjectIds: string[] | undefined, -): Array { +): EffectiveFolder[] { const base = listProjectFolders(ctx.db, projectId).map((folder) => ({ ...folder, linkedProjectId: folder.position === 0 ? projectId : null, })); - if (!extraProjectIds?.length) return base; + const group = findGroupForPrimaryProject(ctx.db, projectId); + const primary = base[0]; + const primaryFolder = group?.members.find((member) => member.position === 0); + // Settings name the primary checkout after its membership row, so the + // directory has to be the one they name. + if (group && group.members.length > 1 && primary && primaryFolder) { + base[0] = { ...primary, folder: primaryFolder.folder }; + } + const folders = [...base, ...groupSourceFolders(group, ctx, projectId, base)]; + if (!extraProjectIds?.length) return folders; - const taken = base.map((folder) => folder.folder); + const taken = folders.map((folder) => folder.folder); const extras = extraProjectIds.map((extraId, index) => { const project = ctx.db .select() @@ -71,7 +88,7 @@ export function effectiveProjectFolders( return { id: null, projectId, - position: base.length + index, + position: folders.length + index, folder, repoPath: project.repoPath, repoUrl: project.repoUrl, @@ -79,7 +96,55 @@ export function effectiveProjectFolders( linkedProjectId: extraId, }; }); - return [...base, ...extras]; + return [...folders, ...extras]; +} + +function groupSourceFolders( + group: ProjectGroup | null, + ctx: HostServiceContext, + projectId: string, + base: EffectiveFolder[], +): EffectiveFolder[] { + if (!group) return []; + + const taken = base.map((folder) => folder.folder); + const checkedOut = new Set( + base.flatMap((folder) => (folder.repoPath ? [folder.repoPath] : [])), + ); + const folders: EffectiveFolder[] = []; + for (const member of group.members) { + if (member.projectId === projectId) continue; + const project = ctx.db + .select() + .from(projects) + .where(eq(projects.id, member.projectId)) + .get(); + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Source folder "${member.folder}" is not set up on this host`, + }); + } + if (checkedOut.has(project.repoPath)) continue; + const folder = deduplicateFolderName( + sanitizeFolderName(member.folder) ?? + defaultFolderNameForRepo(project.repoPath), + taken, + ); + taken.push(folder); + checkedOut.add(project.repoPath); + folders.push({ + id: null, + projectId, + position: base.length + folders.length, + folder, + repoPath: project.repoPath, + repoUrl: project.repoUrl, + baseBranch: member.baseBranch, + linkedProjectId: member.projectId, + }); + } + return folders; } function isDirectory(path: string): boolean { diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts index 7e0faf87ea5..e1f322cd5c7 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/setup-terminal.ts @@ -1,5 +1,6 @@ import { eq } from "drizzle-orm"; import { projects, workspaces } from "../../../../db/schema"; +import { listWorkspaceCheckouts } from "../../../../projects/workspace-checkouts"; import { resolveScript, shellSingleQuote, @@ -63,18 +64,41 @@ export async function startSetupTerminalIfPresent( return { terminal: null, warning: null, chained: false }; } - const resolved = resolveInitialCommand({ - repoPath: row.repoPath, + const checkouts = listWorkspaceCheckouts(args.ctx.db, args.workspaceId, { projectId: row.projectId, + repoPath: row.repoPath, worktreePath: row.worktreePath, }); - if (!resolved) { + const resolved = checkouts.flatMap((checkout) => { + const script = resolveInitialCommand({ + repoPath: checkout.repoPath, + projectId: checkout.projectId, + worktreePath: checkout.worktreePath, + }); + return script ? [{ ...checkout, ...script }] : []; + }); + const primaryOnly = + resolved.length === 1 && resolved[0]?.worktreePath === row.worktreePath; + const command = primaryOnly + ? resolved[0]?.initialCommand + : resolved + .map((script) => { + const enter = [`cd ${shellSingleQuote(script.worktreePath)}`]; + if (script.cwd) enter.push(`cd ${shellSingleQuote(script.cwd)}`); + return [...enter, script.initialCommand].join(" && "); + }) + .join(" && "); + if (!command) { return { terminal: null, warning: null, chained: false }; } - const setupCommand = args.chainCommand - ? `${resolved.initialCommand} && ${args.chainCommand}` - : resolved.initialCommand; + // The chain ends in whichever folder ran last, so the agent is sent back + // to the primary checkout it would have started in. + const chainCommand = + args.chainCommand && !primaryOnly + ? `cd ${shellSingleQuote(row.worktreePath)} && ${args.chainCommand}` + : args.chainCommand; + const setupCommand = chainCommand ? `${command} && ${chainCommand}` : command; // `\\n`, not `\n`: the initial command is typed into the PTY, so a real // newline here would submit the line mid-quote instead of reaching printf. const preamble = (args.preamble ?? []) @@ -91,7 +115,7 @@ export async function startSetupTerminalIfPresent( db: args.ctx.db, eventBus: args.ctx.eventBus, initialCommand, - ...(resolved.cwd && { cwd: resolved.cwd }), + ...(primaryOnly && resolved[0]?.cwd && { cwd: resolved[0].cwd }), }); if ("error" in result) { return { diff --git a/packages/host-service/test/integration/multi-repo-lifecycle.integration.test.ts b/packages/host-service/test/integration/multi-repo-lifecycle.integration.test.ts new file mode 100644 index 00000000000..55ecb35374d --- /dev/null +++ b/packages/host-service/test/integration/multi-repo-lifecycle.integration.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { workspaceRepos } from "../../src/db/schema"; +import { shellSingleQuote } from "../../src/runtime/setup/config"; +import { + buildTeardownCommandFromShell, + resolveWorkspaceTeardown, +} from "../../src/runtime/teardown/teardown"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { seedProject, seedWorkspace } from "../helpers/seed"; + +interface Scenario { + host: TestHost; + root: string; + workspaceId: string; + primaryProjectId: string; + primaryWorktree: string; + secondaryWorktree: string; + dispose(): Promise; +} + +async function createScenario(options: { + primaryTeardown?: string; + secondaryTeardown?: string; +}): Promise { + const host = await createTestHost(); + const root = mkdtempSync(join(tmpdir(), "multi-repo-lifecycle-")); + const container = join(root, "container"); + + const repos = ["town", "roster"].map((folder, position) => { + const repoPath = join(root, folder); + const worktreePath = join(container, folder); + mkdirSync(join(repoPath, ".superset"), { recursive: true }); + mkdirSync(worktreePath, { recursive: true }); + const teardown = + position === 0 ? options.primaryTeardown : options.secondaryTeardown; + if (teardown) { + writeFileSync( + join(repoPath, ".superset", "config.json"), + JSON.stringify({ teardown: [teardown] }), + ); + } + return { + folder, + position, + repoPath, + worktreePath, + projectId: seedProject(host, { repoPath }).id, + }; + }); + + const primary = repos[0]; + if (!primary) throw new Error("scenario needs a primary repo"); + const { id: workspaceId } = seedWorkspace(host, { + projectId: primary.projectId, + worktreePath: primary.worktreePath, + branch: "feature/lifecycle", + }); + for (const repo of repos) { + host.db + .insert(workspaceRepos) + .values({ + id: randomUUID(), + workspaceId, + position: repo.position, + projectId: repo.projectId, + folder: repo.folder, + worktreePath: repo.worktreePath, + branch: "feature/lifecycle", + }) + .run(); + } + + return { + host, + root, + workspaceId, + primaryProjectId: primary.projectId, + primaryWorktree: primary.worktreePath, + secondaryWorktree: repos[1]?.worktreePath ?? "", + dispose: async () => { + await host.dispose(); + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +let scenario: Scenario | null = null; + +afterEach(async () => { + await scenario?.dispose(); + scenario = null; +}); + +function teardownFor(current: Scenario, homeDir: string) { + return resolveWorkspaceTeardown({ + db: current.host.db, + workspaceId: current.workspaceId, + projectId: current.primaryProjectId, + repoPath: join(current.root, "town"), + worktreePath: current.primaryWorktree, + homeDir, + }); +} + +describe("teardown across a project's source folders", () => { + test("runs each folder's command, in folder order, from its own checkout", async () => { + scenario = await createScenario({ + primaryTeardown: "stop-town", + secondaryTeardown: "stop-roster", + }); + const homeDir = mkdtempSync(join(tmpdir(), "multi-repo-home-")); + + const resolved = teardownFor(scenario, homeDir); + + expect(resolved?.initialCommand).toBe( + buildTeardownCommandFromShell( + `cd ${shellSingleQuote(scenario.primaryWorktree)} && stop-town && cd ${shellSingleQuote(scenario.secondaryWorktree)} && stop-roster`, + ), + ); + rmSync(homeDir, { recursive: true, force: true }); + }); + + test("runs a folder that defines one even when the primary does not", async () => { + scenario = await createScenario({ secondaryTeardown: "stop-roster" }); + const homeDir = mkdtempSync(join(tmpdir(), "multi-repo-home-")); + + const resolved = teardownFor(scenario, homeDir); + + expect(resolved?.initialCommand).toBe( + buildTeardownCommandFromShell( + `cd ${shellSingleQuote(scenario.secondaryWorktree)} && stop-roster`, + ), + ); + rmSync(homeDir, { recursive: true, force: true }); + }); + + test("is exactly the single-repo command when only the primary defines one", async () => { + scenario = await createScenario({ primaryTeardown: "stop-town" }); + const homeDir = mkdtempSync(join(tmpdir(), "multi-repo-home-")); + + const resolved = teardownFor(scenario, homeDir); + + expect(resolved?.initialCommand).toBe("exec bash -c 'stop-town'"); + rmSync(homeDir, { recursive: true, force: true }); + }); + + test("skips when no folder defines one", async () => { + scenario = await createScenario({}); + const homeDir = mkdtempSync(join(tmpdir(), "multi-repo-home-")); + + expect(teardownFor(scenario, homeDir)).toBeNull(); + rmSync(homeDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts b/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts index 277e9ceb506..ad01474679a 100644 --- a/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts +++ b/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts @@ -8,6 +8,7 @@ import { workspaces, } from "../../src/db/schema"; import { runMultiRepoBackfill } from "../../src/runtime/multi-repo-backfill"; +import { runProjectGroupBackfill } from "../../src/runtime/project-group-backfill"; import { createTestHost, type TestHost } from "../helpers/createTestHost"; import { createGitFixture, type GitFixture } from "../helpers/git-fixture"; import { seedProject, seedWorkspace } from "../helpers/seed"; @@ -244,6 +245,126 @@ describe("multi-repo project folders", () => { }); }); +describe("a project over several source folders", () => { + test("checks out every member and starts the agent in the primary", async () => { + scenario = await createScenario(); + const { host, primary, secondary, projectId, secondaryProjectId } = + scenario; + + const { group } = await host.trpc.projectGroups.create.mutate({ + name: "platform", + }); + await host.trpc.projectGroups.addMember.mutate({ + groupId: group.id, + projectId, + }); + await host.trpc.projectGroups.addMember.mutate({ + groupId: group.id, + projectId: secondaryProjectId, + folder: "api", + }); + + const result = await host.trpc.workspaces.create.mutate({ + projectId, + name: "platform-work", + branch: "feature/platform", + skipBranchPrefix: true, + runSetup: false, + }); + + const row = host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result.workspace.id)) + .get(); + const repos = repoRows(host, result.workspace.id); + expect(repos.map((repo) => repo.projectId)).toEqual([ + projectId, + secondaryProjectId, + ]); + expect(repos.map((repo) => repo.folder)).toEqual([ + basename(primary.repoPath), + "api", + ]); + for (const repo of repos) { + expect(repo.worktreePath).toBe(join(row?.rootPath ?? "", repo.folder)); + expect(existsSync(join(repo.worktreePath, ".git"))).toBe(true); + } + expect(row?.worktreePath).toBe(repos[0]?.worktreePath ?? ""); + + const folders = await host.trpc.project.folders.list.query({ projectId }); + expect(folders.folders).toHaveLength(1); + expect(folders.folders[0]?.repoPath).toBe(primary.repoPath); + + const list = await secondary.git.raw(["worktree", "list", "--porcelain"]); + expect(list).toContain(repos[1]?.worktreePath ?? ""); + }); + + test("checks out every member when the primary is also its own backfilled project", async () => { + scenario = await createScenario(); + const { host, projectId, secondaryProjectId } = scenario; + + runProjectGroupBackfill({ db: host.db }); + const { group } = await host.trpc.projectGroups.create.mutate({ + name: "platform", + }); + await host.trpc.projectGroups.addMember.mutate({ + groupId: group.id, + projectId, + folder: "web", + }); + await host.trpc.projectGroups.addMember.mutate({ + groupId: group.id, + projectId: secondaryProjectId, + folder: "api", + }); + + const result = await host.trpc.workspaces.create.mutate({ + projectId, + name: "platform-work", + branch: "feature/platform-backfilled", + skipBranchPrefix: true, + runSetup: false, + }); + + const repos = repoRows(host, result.workspace.id); + expect(repos.map((repo) => repo.projectId)).toEqual([ + projectId, + secondaryProjectId, + ]); + expect(repos.map((repo) => repo.folder)).toEqual(["web", "api"]); + }); + + test("a single-member project creates exactly the single-repo workspace", async () => { + scenario = await createScenario(); + const { host, projectId } = scenario; + + const { group } = await host.trpc.projectGroups.create.mutate({ + name: "solo project", + }); + await host.trpc.projectGroups.addMember.mutate({ + groupId: group.id, + projectId, + }); + + const result = await host.trpc.workspaces.create.mutate({ + projectId, + name: "solo", + branch: "feature/solo", + skipBranchPrefix: true, + runSetup: false, + }); + + const row = host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, result.workspace.id)) + .get(); + expect(row?.rootPath).toBeNull(); + expect(basename(row?.worktreePath ?? "")).toBe("solo"); + }); +}); + describe("multi-repo backfill", () => { test("materializes position-0 rows once and stays a no-op after", async () => { scenario = await createScenario(); From ed90d315df9bc514eceb99c49b0eb0be88ea5c95 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 01:37:28 +0530 Subject: [PATCH 09/14] chore(desktop): default the multi-repo flag on in dev builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding the container needs the flag on, and the local dev account is not in the PostHog release condition — the same reason the plugins flag falls back to the dev build a few lines above it. --- .../DashboardSidebarHeader/DashboardSidebarHeader.tsx | 3 ++- .../DashboardSidebarWorkspacesHeader.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index 4ff4fc54426..84447ca2149 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -173,7 +173,8 @@ export function DashboardSidebarHeader({ (useFeatureFlagEnabled(FEATURE_FLAGS.PLUGINS) ?? false) || env.NODE_ENV === "development"; const isMultiRepoEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? false; + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? + import.meta.env.DEV; const { myFailedCount, hasAutomations, automationsPending } = useFailedAutomations(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx index 50e19250cda..225f0862573 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspacesHeader/DashboardSidebarWorkspacesHeader.tsx @@ -78,7 +78,8 @@ export function DashboardSidebarWorkspacesHeader({ const openEmptyProject = useOpenEmptyProjectModal(); const openMultiRepoProject = useOpenMultiRepoProjectModal(); const isMultiRepoEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? false; + useFeatureFlagEnabled(FEATURE_FLAGS.MULTI_REPO_PROJECTS) ?? + import.meta.env.DEV; const openNewWorkspace = useOpenNewWorkspaceForLocalProject(); const openNewProject = useOpenNewProjectModal(); const openTemplateGallery = useOpenTemplateGalleryModal(); From bf5fbc6f5bf0b6abb3156c902e3c8d77d237c382 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 10:15:23 +0530 Subject: [PATCH 10/14] feat: follow every source folder of a project at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces still described the primary checkout only. The Run button ran the project's `run` script from the workspace's primary worktree. It now resolves one per checkout and starts them together — `cd && …` per folder, backgrounded and waited on, because a run script is usually a server and chaining would leave all but the first unstarted. One folder resolves to the command a single-repo workspace has always run. `GitWatcher` attached one watcher per workspace, off `workspaces.worktree_path`, so editing a file in any other checkout emitted no `git:changed` at all — per-folder status went stale until a window focus or the 30s refetch. Watch state is now keyed per checkout (`.git/` watcher, worktree subscription and gitignored-dir set each), while batching, debouncing and the emitted event stay per workspace: consumers still see one coalesced signal. A secondary checkout contributes a broad refresh rather than paths, which are read as primary-relative downstream. The top-bar Changes pill describes the selected folder, so a dirty sibling was invisible. It now carries a dot when another folder has changes, and renders even when the selected folder is clean. --- .../ChangesControl/ChangesControl.tsx | 36 +++- .../RepoFolderPicker/RepoFolderPicker.tsx | 2 +- .../hooks/useRepoDirtyCounts/index.ts | 0 .../useRepoDirtyCounts/useRepoDirtyCounts.ts | 0 .../composeRepoRunCommands.test.ts | 88 +++++++++ .../composeRepoRunCommands.ts | 49 +++++ .../useV2WorkspaceRun/useV2WorkspaceRun.ts | 36 +++- docs/cloud-sandbox-mismatches.md | 21 ++ .../src/events/git-watcher-failure.test.ts | 33 ++-- .../host-service/src/events/git-watcher.ts | 179 +++++++++++++----- .../src/runtime/filesystem/filesystem.ts | 13 ++ .../multi-repo-workspace.integration.test.ts | 47 +++++ packages/i18n/locales/cs/messages.po | 3 + packages/i18n/locales/de/messages.po | 3 + packages/i18n/locales/en/messages.po | 3 + packages/i18n/locales/es/messages.po | 3 + packages/i18n/locales/fr/messages.po | 3 + packages/i18n/locales/id/messages.po | 3 + packages/i18n/locales/it/messages.po | 3 + packages/i18n/locales/ja/messages.po | 3 + packages/i18n/locales/ko/messages.po | 3 + packages/i18n/locales/nl/messages.po | 3 + packages/i18n/locales/pl/messages.po | 3 + packages/i18n/locales/pt-BR/messages.po | 3 + packages/i18n/locales/ru/messages.po | 3 + packages/i18n/locales/tr/messages.po | 3 + packages/i18n/locales/vi/messages.po | 3 + packages/i18n/locales/zh-CN/messages.po | 3 + packages/i18n/locales/zh-TW/messages.po | 3 + 29 files changed, 484 insertions(+), 71 deletions(-) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{components/WorkspaceSidebar/components/RepoFolderPicker => }/hooks/useRepoDirtyCounts/index.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/{components/WorkspaceSidebar/components/RepoFolderPicker => }/hooks/useRepoDirtyCounts/useRepoDirtyCounts.ts (100%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/composeRepoRunCommands.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/composeRepoRunCommands.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/ChangesControl.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/ChangesControl.tsx index b69e1d3feab..3f03199d57b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/ChangesControl.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/ChangesControl/ChangesControl.tsx @@ -2,6 +2,8 @@ import { useLingui } from "@lingui/react/macro"; import { cn } from "@superset/ui/utils"; import { GitCompareArrows } from "lucide-react"; import { memo, useMemo } from "react"; +import { useRepoDirtyCounts } from "../../hooks/useRepoDirtyCounts"; +import { useWorkspaceRepos } from "../../hooks/useWorkspaceRepos"; import { useWorkspaceGitStatus } from "../../providers/WorkspaceGitStatusProvider"; import { changesPillStats } from "./changesPillStats"; import { PRStatusGroup } from "./components/PRStatusGroup"; @@ -41,6 +43,21 @@ export const ChangesControl = memo(function ChangesControl({ }: ChangesControlProps) { const { t } = useLingui(); const status = useWorkspaceGitStatus(); + const { repos, selected, hasMultipleRepos } = useWorkspaceRepos(workspaceId); + const dirtyCounts = useRepoDirtyCounts({ + workspaceId, + repos, + enabled: hasMultipleRepos, + }); + // The pill describes the selected folder; the rest of the project would + // otherwise change without a trace anywhere in the top bar. + const otherFolderChanges = repos.reduce( + (total, repo) => + repo.folder === selected?.folder + ? total + : total + (dirtyCounts[repo.folder] ?? 0), + 0, + ); const { flowState, onRetry } = usePRFlowState(workspaceId); const stats = useMemo( () => (status.data ? changesPillStats(status.data) : null), @@ -61,10 +78,11 @@ export const ChangesControl = memo(function ChangesControl({ flowState.pr != null); const visibleStats = !hasPr && stats != null && stats.fileCount > 0 ? stats : null; + const showsOtherFolders = !hasPr && otherFolderChanges > 0; return (
- {visibleStats && ( + {(visibleStats || showsOtherFolders) && ( )} {flowState.kind === "no-pr" ? ( @@ -97,7 +123,7 @@ export const ChangesControl = memo(function ChangesControl({ workspaceId={workspaceId} sync={flowState.sync} onRefresh={onRetry} - compact={visibleStats != null} + compact={visibleStats != null || showsOtherFolders} /> ) : ( { + it("runs what a single-repo workspace has always run", () => { + expect( + composeRepoRunCommands([ + { path: "/w/town", commands: ["bun dev"], isPrimary: true }, + ]), + ).toEqual({ commands: ["bun dev"] }); + }); + + it("keeps a configured cwd for the terminal to apply", () => { + expect( + composeRepoRunCommands([ + { + path: "/w/town", + commands: ["bun dev"], + cwd: "apps/web", + isPrimary: true, + }, + ]), + ).toEqual({ commands: ["bun dev"], cwd: "apps/web" }); + }); +}); + +describe("a project over several source folders", () => { + it("starts every folder that defines one, together", () => { + expect( + composeRepoRunCommands([ + { path: "/w/town", commands: ["bun dev"], isPrimary: true }, + { + path: "/w/roster", + commands: ["bun install", "bun start"], + isPrimary: false, + }, + ]), + ).toEqual({ + commands: [ + "cd '/w/town' && bun dev & cd '/w/roster' && bun install && bun start & wait", + ], + }); + }); + + it("enters a folder's configured cwd under its checkout", () => { + expect( + composeRepoRunCommands([ + { path: "/w/town", commands: ["bun dev"], isPrimary: true }, + { + path: "/w/roster", + commands: ["bun start"], + cwd: "server", + isPrimary: false, + }, + ]), + ).toEqual({ + commands: [ + "cd '/w/town' && bun dev & cd '/w/roster' && cd 'server' && bun start & wait", + ], + }); + }); + + it("runs a secondary folder from its own checkout when it is the only one", () => { + expect( + composeRepoRunCommands([ + { path: "/w/town", commands: [], isPrimary: true }, + { path: "/w/roster", commands: ["bun start"], isPrimary: false }, + ]), + ).toEqual({ commands: ["cd '/w/roster' && bun start"] }); + }); + + it("is nothing when no folder defines one", () => { + expect( + composeRepoRunCommands([ + { path: "/w/town", commands: [], isPrimary: true }, + { path: "/w/roster", commands: [], isPrimary: false }, + ]), + ).toBeNull(); + }); + + it("quotes a path that would otherwise break the shell", () => { + const composed = composeRepoRunCommands([ + { path: "/w/it's town", commands: ["bun dev"], isPrimary: false }, + ]); + + expect(composed?.commands[0]).toBe("cd '/w/it'\\''s town' && bun dev"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/composeRepoRunCommands.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/composeRepoRunCommands.ts new file mode 100644 index 00000000000..c06599b2a9b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/composeRepoRunCommands.ts @@ -0,0 +1,49 @@ +export interface RepoRun { + path: string; + commands: readonly string[]; + cwd?: string; + isPrimary: boolean; +} + +export interface ComposedRepoRun { + commands: string[]; + cwd?: string; +} + +function quote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function enter(repo: RepoRun): string[] { + return repo.cwd + ? [`cd ${quote(repo.path)}`, `cd ${quote(repo.cwd)}`] + : [`cd ${quote(repo.path)}`]; +} + +/** + * The Run button's command for a project over several source folders: every + * folder that defines one, each from its own checkout, started together and + * waited on — a folder's run is usually a server, so chaining them would + * leave all but the first unstarted. + * + * One folder resolves to what a single-repo workspace has always run: the + * commands alone, the terminal's own cwd being the checkout. + */ +export function composeRepoRunCommands( + repos: readonly RepoRun[], +): ComposedRepoRun | null { + const runnable = repos.filter((repo) => repo.commands.length > 0); + if (runnable.length === 0) return null; + + const only = runnable[0]; + if (runnable.length === 1 && only) { + return only.isPrimary + ? { commands: [...only.commands], ...(only.cwd && { cwd: only.cwd }) } + : { commands: [[...enter(only), ...only.commands].join(" && ")] }; + } + + const jobs = runnable.map((repo) => + [...enter(repo), ...repo.commands].join(" && "), + ); + return { commands: [`${jobs.join(" & ")} & wait`] }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts index 0384046b2e2..664203962ce 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspaceRun/useV2WorkspaceRun.ts @@ -20,6 +20,8 @@ import { selectWorkspaceRunDefinition } from "shared/workspace-run-definition"; import type { StoreApi } from "zustand/vanilla"; import type { PaneViewerData, TerminalPaneData } from "../../types"; import type { TerminalLauncher } from "../useV2TerminalLauncher"; +import { useWorkspaceRepos } from "../useWorkspaceRepos"; +import { composeRepoRunCommands } from "./composeRepoRunCommands"; const CTRL_C_INPUT = "\u0003"; const TERMINAL_GONE_ERROR_MESSAGES = [ @@ -116,12 +118,36 @@ export function useV2WorkspaceRun({ ); // Session workspaces (null projectId) have no project config; only global - // terminal presets can define their run. - const { data: configRunDefinition } = - workspaceTrpc.config.getWorkspaceRunDefinition.useQuery( - { projectId: projectId ?? "" }, - { enabled: projectId !== null }, + // terminal presets can define their run. A project over several source + // folders contributes one definition per checkout. + const { repos } = useWorkspaceRepos(workspaceId); + const runTargets = useMemo( + () => + projectId === null + ? [] + : repos.length > 0 + ? repos + : [{ projectId, folder: "", path: "" }], + [projectId, repos], + ); + const repoRunQueries = workspaceTrpc.useQueries((t) => + runTargets.map((repo) => + t.config.getWorkspaceRunDefinition({ projectId: repo.projectId }), + ), + ); + const configRunDefinition = useMemo(() => { + const composed = composeRepoRunCommands( + runTargets.map((repo, index) => ({ + path: repo.path, + commands: repoRunQueries[index]?.data?.commands ?? [], + ...(repoRunQueries[index]?.data?.cwd && { + cwd: repoRunQueries[index]?.data?.cwd, + }), + isPrimary: index === 0, + })), ); + return composed ? { ...composed, projectId } : null; + }, [projectId, repoRunQueries, runTargets]); const resolvedMatchedPresets = useMemo( () => diff --git a/docs/cloud-sandbox-mismatches.md b/docs/cloud-sandbox-mismatches.md index a687b2f52b6..a154bfe1306 100644 --- a/docs/cloud-sandbox-mismatches.md +++ b/docs/cloud-sandbox-mismatches.md @@ -45,6 +45,27 @@ hosts table, so anything that resolves a host through it degrades: the remote version gate has nothing to check (skipped for cloud), and the unreachable overlay renders "Unknown host". +**A project over several source folders has no cloud counterpart, and the +box models multi-repo differently.** Locally a Project is `project_groups` + +`project_group_members` in `host.db`, and a workspace built from one is a +single `workspaces` row with a `workspace_repos` row per checkout under one +container — that is what the repo picker, per-folder git status, the Run +button and the chained lifecycle scripts all read. On cloud the repository +list comes from the *environment* (`environment_repositories`, fixed at +create into `cloud_workspace_repositories`) and `runSandboxSelfSeed` seeds +**one project and one workspace row per repository**, with no +`workspace_repos` rows at all. Consequences, none of them fixed: a local +Project's folder list does not travel to a cloud workspace; every +`workspace_repos`-shaped surface degrades to the primary checkout on cloud; +per-checkout git watching attaches once; and lifecycle scripts on the box +come from the single hooks checkout (`hooksRepositoryId` → `hooksPath`), +not per folder. Converging the seeding onto one workspace + `workspace_repos` +is what would let the desktop's multi-repo surfaces work on cloud unchanged; +until then treat "multi-repo" as two separate features with one name. Note +also that cloud repositories must be connected GitHub repos in a single +installation (`loadRepositories` rejects a mixed set), so a source folder +with no remote can exist locally and cannot exist on cloud. + ## Addressing and auth **The address is brokered and expires.** A sandbox has no stable URL the diff --git a/packages/host-service/src/events/git-watcher-failure.test.ts b/packages/host-service/src/events/git-watcher-failure.test.ts index 9efe2958f5a..1d42bf7a21d 100644 --- a/packages/host-service/src/events/git-watcher-failure.test.ts +++ b/packages/host-service/src/events/git-watcher-failure.test.ts @@ -29,7 +29,7 @@ class Stream implements AsyncIterable, AsyncIterator { } } interface Internals { - watched: Map; + watched: Map; pendingBatches: Map; interest: Map; rescan(): Promise; @@ -59,16 +59,19 @@ for (const failure of ["reject", "end", "subscribe", "git"] as const) { const streams: Stream[] = []; let failSubscribe = failure === "subscribe"; let backingOff = false; + const service = () => ({ + watchPath: () => { + if (failSubscribe) throw new Error("subscription failed"); + const stream = new Stream(); + streams.push(stream); + return stream; + }, + }); const filesystem = { - getServiceForPrimaryWorktree: () => ({ - watchPath: () => { - if (failSubscribe) throw new Error("subscription failed"); - const stream = new Stream(); - streams.push(stream); - return stream; - }, - }), + getServiceForPrimaryWorktree: service, + getServiceForCheckout: service, refreshWatcherIgnores: async () => false, + refreshCheckoutWatcherIgnores: async () => false, isWatchAttachBackingOff: () => backingOff, }; const store = new GitStatusStore(); @@ -109,7 +112,11 @@ for (const failure of ["reject", "end", "subscribe", "git"] as const) { }); watcher.watchWorkspace(workspaceId); await waitFor(() => states.includes(true)); - const oldWatcher = internals.watched.get(workspaceId)?.watcher; + const entryOf = (id: string) => + [...internals.watched.values()].find( + (candidate) => candidate.workspaceId === id, + ); + const oldWatcher = entryOf(workspaceId)?.watcher; if (failure !== "subscribe") { await read(); await read(); @@ -134,15 +141,15 @@ for (const failure of ["reject", "end", "subscribe", "git"] as const) { backingOff = true; const attachesBeforeBackoff = streams.length; await internals.rescan(); - expect(internals.watched.has(workspaceId)).toBe(false); + expect(entryOf(workspaceId)).toBeUndefined(); expect(streams.length).toBe(attachesBeforeBackoff); backingOff = false; await internals.rescan(); - expect(internals.watched.has(workspaceId)).toBe(true); + expect(entryOf(workspaceId)).toBeDefined(); // A late error on the replaced watcher must not drop its replacement. oldWatcher?.emit("error", new Error("late old watcher error")); - expect(internals.watched.has(workspaceId)).toBe(true); + expect(entryOf(workspaceId)).toBeDefined(); expect(states.at(-1)).toBe(true); const reattached = computations; await read(); diff --git a/packages/host-service/src/events/git-watcher.ts b/packages/host-service/src/events/git-watcher.ts index bee8c09319c..faf06094416 100644 --- a/packages/host-service/src/events/git-watcher.ts +++ b/packages/host-service/src/events/git-watcher.ts @@ -5,6 +5,7 @@ import type { FsWatchEvent } from "@superset/workspace-fs/host"; import { and, eq, isNull } from "drizzle-orm"; import type { HostDb } from "../db/index.ts"; import { workspaces } from "../db/schema.ts"; +import { listWorkspaceCheckouts } from "../projects/workspace-checkouts.ts"; import type { WorkspaceFilesystemManager } from "../runtime/filesystem/index.ts"; import { listGitIgnoredDirs } from "../runtime/git/index.ts"; @@ -306,7 +307,7 @@ export class GitWatcher { if (this.closed) return; const count = this.interest.get(workspaceId) ?? 0; this.interest.set(workspaceId, count + 1); - if (count === 0 && !this.watched.has(workspaceId)) { + if (count === 0 && !this.isWatched(workspaceId)) { void this.attachFromDb(workspaceId); } } @@ -337,12 +338,26 @@ export class GitWatcher { } } - private async attachFromDb(workspaceId: string): Promise { - if (this.closed) return; - let row: { worktreePath: string } | undefined; + /** Watch state is per checkout; everything downstream of an event is per workspace. */ + private watchKey(workspaceId: string, worktreePath: string): string { + return `${workspaceId}\u0000${worktreePath}`; + } + + private isWatched(workspaceId: string): boolean { + for (const entry of this.watched.values()) { + if (entry.workspaceId === workspaceId) return true; + } + return false; + } + + private checkoutsOf(workspaceId: string): string[] { + let row: { projectId: string | null; worktreePath: string } | undefined; try { row = this.db - .select({ worktreePath: workspaces.worktreePath }) + .select({ + projectId: workspaces.projectId, + worktreePath: workspaces.worktreePath, + }) .from(workspaces) .where( and(eq(workspaces.id, workspaceId), isNull(workspaces.archivedAt)), @@ -353,27 +368,71 @@ export class GitWatcher { workspaceId, error, }); - return; + return []; + } + if (!row) return []; + const primary = row.worktreePath; + try { + return listWorkspaceCheckouts(this.db, workspaceId, { + projectId: row.projectId ?? "", + repoPath: primary, + worktreePath: primary, + }).map((checkout) => checkout.worktreePath); + } catch (error) { + // The workspace still has a primary checkout to watch; a secondary + // one joins on the next sweep. + console.error("[git-watcher] checkout lookup failed", { + workspaceId, + error, + }); + return [primary]; + } + } + + private async attachFromDb(workspaceId: string): Promise { + if (this.closed) return; + // Nothing to attach right now for a missing / archived row. If it + // appears later (e.g. `watchWorkspace` raced workspace creation), the + // 30s sweep retries any still-interested workspace that never attached. + for (const [index, worktreePath] of this.checkoutsOf( + workspaceId, + ).entries()) { + await this.attachWatcher(workspaceId, worktreePath, index === 0); } - // Not found / archived: nothing to attach right now. If the row appears - // later (e.g. `watchWorkspace` raced workspace creation), the 30s sweep - // retries any still-interested workspace that never attached. - if (!row) return; - await this.attachWatcher(workspaceId, row.worktreePath); } private stopWatching(workspaceId: string): void { - const entry = this.watched.get(workspaceId); - if (entry) { + let hadEntry = false; + for (const [key, entry] of [...this.watched.entries()]) { + if (entry.workspaceId !== workspaceId) continue; + hadEntry = true; entry.watcher.close(); entry.disposeWorktreeWatch(); - this.watched.delete(workspaceId); - this.notifyWatchState(workspaceId, false); + this.watched.delete(key); + this.ignoredDirs.delete(key); } + if (hadEntry) this.notifyWatchState(workspaceId, false); this.ignoredDirs.delete(workspaceId); this.discardBatch(workspaceId); } + /** + * Drop one checkout's watchers — its watcher died, and the rescan + * re-attaches it. The workspace stays watched while another checkout is. + */ + private stopWatchingCheckout(key: string): void { + const entry = this.watched.get(key); + if (!entry) return; + entry.watcher.close(); + entry.disposeWorktreeWatch(); + this.watched.delete(key); + this.ignoredDirs.delete(key); + if (!this.isWatched(entry.workspaceId)) { + this.notifyWatchState(entry.workspaceId, false); + this.discardBatch(entry.workspaceId); + } + } + close(): void { this.closed = true; if (this.rescanTimer) { @@ -393,8 +452,8 @@ export class GitWatcher { this.interest.clear(); } - private getOrCreateIgnoredDirsState(workspaceId: string): IgnoredDirsState { - let state = this.ignoredDirs.get(workspaceId); + private getOrCreateIgnoredDirsState(key: string): IgnoredDirsState { + let state = this.ignoredDirs.get(key); if (!state) { state = { dirs: new Set(), @@ -402,7 +461,7 @@ export class GitWatcher { lastRefreshAt: 0, rulesChanged: false, }; - this.ignoredDirs.set(workspaceId, state); + this.ignoredDirs.set(key, state); } return state; } @@ -421,11 +480,11 @@ export class GitWatcher { // started them: unwatchWorkspace() (or unwatch-then-rewatch) mid-refresh // would otherwise re-create ignore state and, on a swap, schedule a // git:changed for a workspace nobody watches any more. - const entry = this.watched.get(workspaceId); + const key = this.watchKey(workspaceId, worktreePath); + const entry = this.watched.get(key); if (!entry) return; - const stillCurrent = () => - !this.closed && this.watched.get(workspaceId) === entry; - const state = this.getOrCreateIgnoredDirsState(workspaceId); + const stillCurrent = () => !this.closed && this.watched.get(key) === entry; + const state = this.getOrCreateIgnoredDirsState(key); if (state.refreshing) return; if ( !force && @@ -448,7 +507,7 @@ export class GitWatcher { // actually shrank; after a swap, force one broad status refresh // to cover anything written during the swap gap. const swapped = await this.filesystem - .refreshWatcherIgnores(workspaceId) + .refreshCheckoutWatcherIgnores(worktreePath) .catch((error) => { console.error("[git-watcher] watcher ignore refresh failed", { workspaceId, @@ -526,9 +585,9 @@ export class GitWatcher { // Anything that emits may also have changed what git ignores (a // build dir appearing, a .gitignore edit) — re-derive the filter // set so the follow-up churn stops emitting. - const watchedEntry = this.watched.get(workspaceId); - if (watchedEntry) { - this.refreshIgnoredDirs(workspaceId, watchedEntry.worktreePath); + for (const entry of this.watched.values()) { + if (entry.workspaceId !== workspaceId) continue; + this.refreshIgnoredDirs(workspaceId, entry.worktreePath); } } @@ -549,16 +608,28 @@ export class GitWatcher { private handleGitDirEvent( workspaceId: string, filename: string | null, + key?: string, ): void { if (!isStatusRelevantGitDirEvent(filename)) return; // `.git/info/exclude` is an ignore-rule source just like .gitignore; - // an edit there can also un-ignore a natively-pruned dir. + // an edit there can also un-ignore a natively-pruned dir. Without a + // checkout to attribute it to, every checkout re-checks. if (filename?.replace(/\\/g, "/") === "info/exclude") { - this.getOrCreateIgnoredDirsState(workspaceId).rulesChanged = true; + for (const candidate of key ? [key] : this.keysOf(workspaceId)) { + this.getOrCreateIgnoredDirsState(candidate).rulesChanged = true; + } } this.markGitDirDirty(workspaceId); } + private keysOf(workspaceId: string): string[] { + const keys: string[] = []; + for (const [key, entry] of this.watched) { + if (entry.workspaceId === workspaceId) keys.push(key); + } + return keys.length > 0 ? keys : [workspaceId]; + } + private markGitDirDirty(workspaceId: string): void { if (this.closed) return; this.getOrCreateBatch(workspaceId).hasGitDir = true; @@ -659,22 +730,26 @@ export class GitWatcher { // per-client `git:watch` cap in event-bus.ts) until the holding // client unwatches or its socket closes — self-healing, unlike a // leaked live watcher. - for (const id of [...this.watched.keys()]) { - if (!existingIds.has(id)) this.stopWatching(id); + for (const entry of [...this.watched.values()]) { + if (!existingIds.has(entry.workspaceId)) + this.stopWatching(entry.workspaceId); } - // Retry attaching for still-interested workspaces that never attached. + // Retry attaching for still-interested workspaces, and for the + // checkouts of a multi-repo one that are not attached yet. for (const id of this.interest.keys()) { - if (this.watched.has(id)) continue; - const worktreePath = worktreePathById.get(id); - if (!worktreePath) continue; - await this.attachWatcher(id, worktreePath); + if (!worktreePathById.has(id)) continue; + for (const [index, worktreePath] of this.checkoutsOf(id).entries()) { + if (this.watched.has(this.watchKey(id, worktreePath))) continue; + await this.attachWatcher(id, worktreePath, index === 0); + } } } private async attachWatcher( workspaceId: string, worktreePath: string, + isPrimary: boolean, ): Promise { if (this.closed) return; @@ -701,7 +776,8 @@ export class GitWatcher { return; } - if (this.closed || this.watched.has(workspaceId)) return; + const key = this.watchKey(workspaceId, worktreePath); + if (this.closed || this.watched.has(key)) return; // Start the worktree watch first so we have a dispose handle to capture // in the .git watcher's error handler closure. This avoids a race where @@ -709,20 +785,20 @@ export class GitWatcher { const disposeWorktreeWatch = this.startWorktreeWatch( workspaceId, worktreePath, + isPrimary, () => { if ( - this.watched.get(workspaceId)?.disposeWorktreeWatch !== - disposeWorktreeWatch + this.watched.get(key)?.disposeWorktreeWatch !== disposeWorktreeWatch ) return; - this.stopWatching(workspaceId); + this.stopWatchingCheckout(key); }, ); let watcher: FSWatcher; try { watcher = watch(gitDir, { recursive: true }, (_event, filename) => { - this.handleGitDirEvent(workspaceId, filename); + this.handleGitDirEvent(workspaceId, filename, key); }); } catch { // fs.watch failed (e.g. directory doesn't exist) @@ -736,8 +812,8 @@ export class GitWatcher { // must not evict the live entry (its resources were released by // stopWatching; closing again is harmless). watcher.close(); - if (this.watched.get(workspaceId)?.watcher !== watcher) return; - this.stopWatching(workspaceId); + if (this.watched.get(key)?.watcher !== watcher) return; + this.stopWatchingCheckout(key); }); // Recheck interest: watchWorkspace()/unwatchWorkspace() can flip the @@ -748,7 +824,7 @@ export class GitWatcher { // race at a time. Don't commit what's no longer wanted. if ( this.closed || - this.watched.has(workspaceId) || + this.watched.has(key) || !this.interest.has(workspaceId) ) { disposeWorktreeWatch(); @@ -756,14 +832,15 @@ export class GitWatcher { return; } - this.watched.set(workspaceId, { + const wasWatched = this.isWatched(workspaceId); + this.watched.set(key, { workspaceId, worktreePath, gitDir, watcher, disposeWorktreeWatch, }); - this.notifyWatchState(workspaceId, true); + if (!wasWatched) this.notifyWatchState(workspaceId, true); this.refreshIgnoredDirs(workspaceId, worktreePath, true); // A change can land in the gap between watchWorkspace() and this line @@ -785,13 +862,14 @@ export class GitWatcher { private startWorktreeWatch( workspaceId: string, worktreePath: string, + isPrimary: boolean, onFailure: () => void = () => {}, ): () => void { let disposed = false; let iterator: AsyncIterator<{ events: FsWatchEvent[] }> | null = null; try { - const service = this.filesystem.getServiceForPrimaryWorktree(workspaceId); + const service = this.filesystem.getServiceForCheckout(worktreePath); const stream = service.watchPath({ absolutePath: worktreePath, }); @@ -835,7 +913,9 @@ export class GitWatcher { continue; } - const ignoredState = this.getOrCreateIgnoredDirsState(workspaceId); + const ignoredState = this.getOrCreateIgnoredDirsState( + this.watchKey(workspaceId, worktreePath), + ); const filtered = filterGitIgnoredEvents( next.value.events, worktreePath, @@ -861,6 +941,11 @@ export class GitWatcher { continue; } + if (!isPrimary) { + this.markWorktreeBroad(workspaceId); + continue; + } + const relativePaths = collectWorktreeBatchPaths( filtered.events, worktreePath, diff --git a/packages/host-service/src/runtime/filesystem/filesystem.ts b/packages/host-service/src/runtime/filesystem/filesystem.ts index 73545098b1d..d55318b0fb7 100644 --- a/packages/host-service/src/runtime/filesystem/filesystem.ts +++ b/packages/host-service/src/runtime/filesystem/filesystem.ts @@ -118,6 +118,19 @@ export class WorkspaceFilesystemManager { ); } + /** + * The two above, addressed by checkout rather than by workspace: a + * multi-repo workspace watches each of its checkouts, and `watchPath` + * takes only a service's own root. + */ + getServiceForCheckout(worktreePath: string): FsHostService { + return this.getServiceForRootPath(worktreePath); + } + + async refreshCheckoutWatcherIgnores(worktreePath: string): Promise { + return await this.watcherManager.refreshIgnores(worktreePath); + } + private getServiceForRootPath(rootPath: string): FsHostService { let service = this.serviceCache.get(rootPath); if (!service) { diff --git a/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts b/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts index ad01474679a..4bdf25f0fdb 100644 --- a/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts +++ b/packages/host-service/test/integration/multi-repo-workspace.integration.test.ts @@ -7,6 +7,8 @@ import { workspaceRepos, workspaces, } from "../../src/db/schema"; +import { GitWatcher } from "../../src/events/git-watcher"; +import { WorkspaceFilesystemManager } from "../../src/runtime/filesystem"; import { runMultiRepoBackfill } from "../../src/runtime/multi-repo-backfill"; import { runProjectGroupBackfill } from "../../src/runtime/project-group-backfill"; import { createTestHost, type TestHost } from "../helpers/createTestHost"; @@ -208,6 +210,51 @@ describe("multi-repo project folders", () => { expect(folders.folders).toHaveLength(1); }); + test("watches every checkout for git activity, not just the primary", async () => { + scenario = await createScenario(); + const { host, secondary, projectId } = scenario; + + await host.trpc.project.folders.add.mutate({ + projectId, + repoPath: secondary.repoPath, + }); + const result = await host.trpc.workspaces.create.mutate({ + projectId, + name: "watched", + branch: "feature/watched", + skipBranchPrefix: true, + runSetup: false, + }); + const expected = repoRows(host, result.workspace.id).map( + (repo) => repo.worktreePath, + ); + + const filesystem = new WorkspaceFilesystemManager({ db: host.db }); + const watcher = new GitWatcher(host.db, filesystem, () => {}); + const watched = () => [ + ...( + watcher as unknown as { + watched: Map; + } + ).watched.values(), + ]; + try { + watcher.watchWorkspace(result.workspace.id); + const deadline = Date.now() + 5_000; + while (watched().length < expected.length && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect( + watched() + .map((entry) => entry.worktreePath) + .sort(), + ).toEqual([...expected].sort()); + } finally { + watcher.close(); + await filesystem.close(); + } + }); + test("git procedures address a secondary checkout through `repo`", async () => { scenario = await createScenario(); const { host, secondary, projectId } = scenario; diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index d0afe091001..7931c22c72c 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# zařízení je offline a ponechá si s msgid "{openCount} open" msgstr "{openCount} otevřených" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} změněných souborů v jiných složkách" + msgid "{partialCount} partial" msgstr "{partialCount} částečně" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 8e5849e4d22..d9c9aff7033 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# Gerät ist offline und behält seine K msgid "{openCount} open" msgstr "{openCount} offen" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} geänderte Dateien in anderen Ordnern" + msgid "{partialCount} partial" msgstr "{partialCount} teilweise" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 7cf3ac40d09..aa370f11a97 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# device is offline and keeps its copy.} msgid "{openCount} open" msgstr "{openCount} open" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} changed files in other folders" + msgid "{partialCount} partial" msgstr "{partialCount} partial" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 5f259376a47..1b7ae5f40f7 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# dispositivo está sin conexión y cons msgid "{openCount} open" msgstr "{openCount} abiertas" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} archivos modificados en otras carpetas" + msgid "{partialCount} partial" msgstr "{partialCount} parciales" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index f5dddc9241d..d86b3ee52c2 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# appareil est hors ligne et conserve sa msgid "{openCount} open" msgstr "{openCount} ouvertes" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} fichiers modifiés dans d'autres dossiers" + msgid "{partialCount} partial" msgstr "{partialCount} partielles" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 414bb052447..4a4982d9006 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# perangkat sedang offline dan tetap men msgid "{openCount} open" msgstr "{openCount} terbuka" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} berkas berubah di folder lain" + msgid "{partialCount} partial" msgstr "{partialCount} sebagian" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index ab43e9bcd8e..2a08878602f 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# dispositivo è offline e conserva la s msgid "{openCount} open" msgstr "{openCount} aperti" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} file modificati in altre cartelle" + msgid "{partialCount} partial" msgstr "{partialCount} parziali" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 40ab48153ee..7016498cef0 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {#台のデバイスがオフラインの msgid "{openCount} open" msgstr "{openCount}件通過" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "他のフォルダで {otherFolderChanges} 件のファイルが変更されています" + msgid "{partialCount} partial" msgstr "{partialCount}件一部通過" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index da98c1b1cad..46e38e6b101 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {기기 #대가 오프라인 상태이며 msgid "{openCount} open" msgstr "{openCount}개 열림" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "다른 폴더에서 {otherFolderChanges}개 파일이 변경됨" + msgid "{partialCount} partial" msgstr "{partialCount}개 부분 달성" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index 50da8de9aae..26afd5be914 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# apparaat is offline en behoudt zijn ko msgid "{openCount} open" msgstr "{openCount} open" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} gewijzigde bestanden in andere mappen" + msgid "{partialCount} partial" msgstr "{partialCount} gedeeltelijk" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 83bf1a2a897..f92284c7b7c 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# urządzenie jest offline i zachowa swo msgid "{openCount} open" msgstr "{openCount} otwartych" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} zmienionych plików w innych folderach" + msgid "{partialCount} partial" msgstr "{partialCount} częściowo" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 32b7e5cb762..a486c9b234b 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# dispositivo está offline e mantém su msgid "{openCount} open" msgstr "{openCount} abertos" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} arquivos alterados em outras pastas" + msgid "{partialCount} partial" msgstr "{partialCount} parciais" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index 34f12a805b2..faac2aebb08 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# устройство офлайн и msgid "{openCount} open" msgstr "{openCount} открыто" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "Изменённых файлов в других папках: {otherFolderChanges}" + msgid "{partialCount} partial" msgstr "{partialCount} частично" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index d9d6fabda2b..9150ac2986f 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# cihaz çevrimdışı ve kopyasını ko msgid "{openCount} open" msgstr "{openCount} açık" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "Diğer klasörlerde {otherFolderChanges} değişen dosya" + msgid "{partialCount} partial" msgstr "{partialCount} kısmi" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index d7061fcadba..4d050477dc7 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# thiết bị đang ngoại tuyến và msgid "{openCount} open" msgstr "{openCount} còn mở" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "{otherFolderChanges} tệp đã thay đổi ở các thư mục khác" + msgid "{partialCount} partial" msgstr "{partialCount} một phần" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index 6d1746f47f3..c3b9c10080e 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# 台设备处于离线状态,将保 msgid "{openCount} open" msgstr "{openCount}项已开启" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "其他文件夹中有 {otherFolderChanges} 个文件已更改" + msgid "{partialCount} partial" msgstr "{partialCount}项部分开启" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index e710d97df03..c6418c73b37 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -809,6 +809,9 @@ msgstr "{offlineHostCount, plural, one {# 台裝置處於離線狀態,將保 msgid "{openCount} open" msgstr "{openCount}項已開啟" +msgid "{otherFolderChanges} changed files in other folders" +msgstr "其他資料夾中有 {otherFolderChanges} 個檔案已變更" + msgid "{partialCount} partial" msgstr "{partialCount}項部分開啟" From 6bdec0c73f87f98e5ed42815fc5246fb5ae9e378 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 11:08:52 +0530 Subject: [PATCH 11/14] feat(host-service): seed a cloud box the way a local multi-repo workspace is stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace over several repositories is one `workspaces` row — `worktree_path` the primary checkout, `root_path` the container — plus a `workspace_repos` row per checkout. That is what the repo picker, per-folder git status, the `repo` argument on `git.*`, the Run button and the watcher all read. The sandbox seed wrote a different shape: a project AND a workspace row per repository, the secondaries under ids derived from the workspace's, and no `workspace_repos` rows at all — so every one of those surfaces degraded to the primary checkout on a cloud box, and per-checkout watching attached once. The box now writes the local shape from the same environment it already reads: one workspace row with `root_path` set when there is more than one repository, one project per repository (a box has no base repo, so the checkout is still the repo), and one repo row per checkout in the environment's order, carrying `baseBranch` where the control plane sent one. Nothing in the sandbox contract moves; `SUPERSET_SANDBOX_REPOSITORIES` already carried everything needed. Boxes seeded the old way are in the wild, so the seed converges them on the next boot rather than leaving two shapes alive: it materializes the repo rows against the project rows that already exist (matched by checkout path, so the ids agent and terminal state reference survive) and deletes the derived workspace rows. Deleting rather than keeping them, because the derived ids were never addressable — the desktop filters sandbox-host rows out of its list, and `terminal_sessions.origin_workspace_id` is ON DELETE SET NULL, so an orphan degrades to a session with no origin instead of a dangling row. A single-repository box is left exactly as it was: one workspace, null `root_path`, no repo rows — `listWorkspaceRepos` synthesizes its primary, and a test asserts the rows byte for byte. The repo rows are rewritten whole rather than merged when the environment's list stops matching (its hooks repository decides which checkout is primary, and an environment can be edited), because moving a repository between positions row by row trips the (workspace, position) unique index. Only when it actually differs, so a restart is still a no-op. Out of scope, and noted in docs/cloud-sandbox-mismatches.md: mapping a local Project's folder list onto a cloud workspace (cloud repositories must be connected GitHub repos in one installation, so a local-only source folder has no cloud counterpart — a product decision, not a mapping), and per-folder lifecycle scripts on the box, which still run from the single hooks checkout. --- docs/cloud-sandbox-mismatches.md | 35 +- .../sandbox-self-seed/sandbox-self-seed.ts | 234 ++++++++++--- packages/host-service/src/trpc/index.ts | 11 +- .../local-workspaces-edge.integration.test.ts | 11 +- .../sandbox-self-seed.integration.test.ts | 312 ++++++++++++++++++ 5 files changed, 537 insertions(+), 66 deletions(-) create mode 100644 packages/host-service/test/integration/sandbox-self-seed.integration.test.ts diff --git a/docs/cloud-sandbox-mismatches.md b/docs/cloud-sandbox-mismatches.md index a154bfe1306..1b5beef3705 100644 --- a/docs/cloud-sandbox-mismatches.md +++ b/docs/cloud-sandbox-mismatches.md @@ -52,19 +52,28 @@ single `workspaces` row with a `workspace_repos` row per checkout under one container — that is what the repo picker, per-folder git status, the Run button and the chained lifecycle scripts all read. On cloud the repository list comes from the *environment* (`environment_repositories`, fixed at -create into `cloud_workspace_repositories`) and `runSandboxSelfSeed` seeds -**one project and one workspace row per repository**, with no -`workspace_repos` rows at all. Consequences, none of them fixed: a local -Project's folder list does not travel to a cloud workspace; every -`workspace_repos`-shaped surface degrades to the primary checkout on cloud; -per-checkout git watching attaches once; and lifecycle scripts on the box -come from the single hooks checkout (`hooksRepositoryId` → `hooksPath`), -not per folder. Converging the seeding onto one workspace + `workspace_repos` -is what would let the desktop's multi-repo surfaces work on cloud unchanged; -until then treat "multi-repo" as two separate features with one name. Note -also that cloud repositories must be connected GitHub repos in a single -installation (`loadRepositories` rejects a mixed set), so a source folder -with no remote can exist locally and cannot exist on cloud. +create into `cloud_workspace_repositories`), not from a Project. The seeding +half is **fixed**: `runSandboxSelfSeed` writes one `workspaces` row (its +`root_path` the container when there is more than one repository) over one +`workspace_repos` row per checkout, one project per repository, so the repo +picker, per-folder git status, the `repo` argument on `git.*` and per-checkout +watching read a box through the same path they read a laptop. It used to seed +one project and one workspace row per repository, under ids derived by +`sandboxRepositoryWorkspaceId`; boxes seeded that way converge on their next +boot — the seed materializes the repo rows against the projects that already +exist and deletes the derived workspace rows. A single-repository box is +untouched by all of this: one row, null `root_path`, no repo rows. + +Still open: a local Project's folder list does not travel to a cloud +workspace, because cloud repositories must be connected GitHub repos in a +single installation (`loadRepositories` rejects a mixed set) — a source folder +with no remote can exist locally and cannot exist on cloud, so what a Project +means on cloud is a product decision, not a mapping. And lifecycle scripts on +the box still come from the single hooks checkout (`hooksRepositoryId` → +`hooksPath`) rather than per folder the way `startSetupTerminalIfPresent` and +`resolveWorkspaceTeardown` chain them locally; the box runs `start` from +`runSandboxStartHook` against one directory, and per-folder hooks would mean +teaching that one call site the checkout list. ## Addressing and auth diff --git a/packages/host-service/src/runtime/sandbox-self-seed/sandbox-self-seed.ts b/packages/host-service/src/runtime/sandbox-self-seed/sandbox-self-seed.ts index b434e15439f..e2b346b0cbd 100644 --- a/packages/host-service/src/runtime/sandbox-self-seed/sandbox-self-seed.ts +++ b/packages/host-service/src/runtime/sandbox-self-seed/sandbox-self-seed.ts @@ -3,20 +3,21 @@ import { createHash } from "node:crypto"; import { existsSync, openSync, readFileSync, writeFileSync } from "node:fs"; import { rm } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { type CloudAgentLaunch, readCloudAgentLaunch, } from "@superset/shared/cloud-agent-launch"; import { SANDBOX_PATHS, + SANDBOX_ROOT_CHECKOUT, type SandboxRepository, sandboxCheckoutDir, sandboxRepositoriesSchema, } from "@superset/shared/sandbox-contract"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import type { HostDb } from "../../db"; -import { projects, workspaces } from "../../db/schema"; +import { projects, workspaceRepos, workspaces } from "../../db/schema"; import { runAgentInWorkspace } from "../../trpc/router/agents/agents"; import { importCloudAttachments } from "../../trpc/router/attachments/attachments"; import { seedDefaultsIfEmpty } from "../../trpc/router/settings/agent-configs"; @@ -31,8 +32,8 @@ import { resolveScript, shellSingleQuote } from "../setup/config"; * Makes a sandbox describe its own workspace, instead of being described from * outside. * - * A cloud workspace's sandbox holds exactly one project and one workspace, and - * both are known before it boots: they are what it was provisioned for. The + * A cloud workspace's sandbox holds one workspace over the repositories it was + * provisioned with, and all of it is known before it boots. The * first version of this reached into the sandbox from the API afterwards — * write a seed script, run it against host.db with better-sqlite3, hope the * shapes still match. That put the schema in two places and made provisioning @@ -359,9 +360,9 @@ async function waitForFlag(path: string, timeoutMs: number): Promise { } /** - * A cloud workspace's id names its primary checkout's row, which is what the - * app opens; every other repository gets a row of its own with an id derived - * from the workspace's, so a restart seeds the same ids. + * The id a build that gave every repository past the first a workspace row of + * its own derived for it. Boxes seeded that way are still in the wild, and + * this is how the seed recognizes those rows to retire them. */ export function sandboxRepositoryWorkspaceId( workspaceId: string, @@ -373,47 +374,194 @@ export function sandboxRepositoryWorkspaceId( return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`; } +interface SandboxCheckout { + position: number; + folder: string; + worktreePath: string; + branch: string; + baseBranch: string | null; + /** The row a build before this one gave this repository; null for the primary. */ + retiredWorkspaceId: string | null; +} + +function sandboxCheckouts(identity: SandboxIdentity): SandboxCheckout[] { + return identity.repositories.map((repo, position) => ({ + position, + folder: + repo.path === SANDBOX_ROOT_CHECKOUT + ? basename(identity.workspaceRoot) + : repo.path, + worktreePath: sandboxCheckoutDir(identity.workspaceRoot, repo.path), + branch: repo.branch, + baseBranch: repo.baseBranch ?? null, + retiredWorkspaceId: + position === 0 + ? null + : sandboxRepositoryWorkspaceId(identity.workspaceId, repo.path), + })); +} + +interface DesiredWorkspaceRepo { + position: number; + projectId: string; + folder: string; + worktreePath: string; + branch: string; + baseBranch: string | null; +} + +function workspaceReposMatch( + stored: Array, + desired: DesiredWorkspaceRepo[], +): boolean { + if (stored.length !== desired.length) return false; + const byPosition = new Map(stored.map((row) => [row.position, row])); + return desired.every((want) => { + const row = byPosition.get(want.position); + return ( + row?.projectId === want.projectId && + row.folder === want.folder && + row.worktreePath === want.worktreePath && + row.branch === want.branch && + row.baseBranch === want.baseBranch + ); + }); +} + +/** + * Seeds the box the way a local multi-repo workspace is stored: one workspace + * row over one `workspace_repos` row per checkout, so `workspace.get`, the + * `repo` argument on `git.*`, the watcher and the picker all read a cloud box + * through the same path they read a laptop. + * + * A single-repository box keeps exactly the rows it always had — one workspace + * with a null `root_path` and no repo rows, which `listWorkspaceRepos` + * synthesizes the primary from. + */ export function runSandboxSelfSeed( db: HostDb, identity: SandboxIdentity, ): void { - const root = identity.workspaceRoot; + const checkouts = sandboxCheckouts(identity); + const primary = checkouts[0]; + if (!primary) return; const now = Date.now(); - identity.repositories.forEach((repo, index) => { - const id = - index === 0 - ? identity.workspaceId - : sandboxRepositoryWorkspaceId(identity.workspaceId, repo.path); - const existing = db - .select({ id: workspaces.id }) + const rootPath = checkouts.length > 1 ? identity.workspaceRoot : null; + + db.transaction((tx) => { + const projectIdByRepoPath = new Map( + tx + .select({ id: projects.id, repoPath: projects.repoPath }) + .from(projects) + .all() + .map((row) => [row.repoPath, row.id] as const), + ); + const projectIdFor = (checkout: SandboxCheckout): string => { + const existing = projectIdByRepoPath.get(checkout.worktreePath); + if (existing) return existing; + const id = crypto.randomUUID(); + tx.insert(projects) + .values({ + id, + repoPath: checkout.worktreePath, + name: + checkout.position === 0 ? identity.projectName : checkout.folder, + createdAt: now, + updatedAt: now, + }) + .run(); + projectIdByRepoPath.set(checkout.worktreePath, id); + return id; + }; + + const existing = tx + .select({ + projectId: workspaces.projectId, + worktreePath: workspaces.worktreePath, + rootPath: workspaces.rootPath, + }) .from(workspaces) - .where(eq(workspaces.id, id)) + .where(eq(workspaces.id, identity.workspaceId)) .get(); - if (existing) return; - const projectId = crypto.randomUUID(); - const worktreePath = sandboxCheckoutDir(root, repo.path); - db.insert(projects) - .values({ - id: projectId, - repoPath: worktreePath, - name: index === 0 ? identity.projectName : repo.path, - createdAt: now, - updatedAt: now, - }) - .run(); - // type='local' because the checkout *is* the repo here — there is no - // base repo it was branched from. - db.insert(workspaces) - .values({ - id, - projectId, - worktreePath, - branch: repo.branch, - name: index === 0 ? identity.workspaceName : repo.path, - type: "local", - createdAt: now, - updatedAt: now, - }) - .run(); + if (!existing) { + // type='local' because the checkout *is* the repo here — there is no + // base repo it was branched from. + tx.insert(workspaces) + .values({ + id: identity.workspaceId, + projectId: projectIdFor(primary), + worktreePath: primary.worktreePath, + rootPath, + branch: primary.branch, + name: identity.workspaceName, + type: "local", + createdAt: now, + updatedAt: now, + }) + .run(); + } else { + // `workspace_repos` position 0 is the workspace's own checkout, and + // half the app reads the column rather than the row. A box seeded + // before the container existed has no `root_path`; one whose + // environment renamed its hooks repository has a new primary. + const primaryProjectId = projectIdFor(primary); + if ( + existing.rootPath !== rootPath || + existing.worktreePath !== primary.worktreePath || + existing.projectId !== primaryProjectId + ) { + tx.update(workspaces) + .set({ + rootPath, + worktreePath: primary.worktreePath, + projectId: primaryProjectId, + updatedAt: now, + }) + .where(eq(workspaces.id, identity.workspaceId)) + .run(); + } + } + + if (checkouts.length > 1) { + const desired = checkouts.map((checkout) => ({ + position: checkout.position, + projectId: projectIdFor(checkout), + folder: checkout.folder, + worktreePath: checkout.worktreePath, + branch: checkout.branch, + baseBranch: checkout.baseBranch, + })); + const stored = tx + .select() + .from(workspaceRepos) + .where(eq(workspaceRepos.workspaceId, identity.workspaceId)) + .all(); + // Replaced whole rather than merged: the environment's list is the + // truth, and a repository that moved position would otherwise trip + // the unique index row by row. Only when it actually differs, so a + // restart leaves every id alone. + if (!workspaceReposMatch(stored, desired)) { + tx.delete(workspaceRepos) + .where(eq(workspaceRepos.workspaceId, identity.workspaceId)) + .run(); + tx.insert(workspaceRepos) + .values( + desired.map((repo) => ({ + id: crypto.randomUUID(), + workspaceId: identity.workspaceId, + createdAt: now, + ...repo, + })), + ) + .run(); + } + } + + const retired = checkouts.flatMap( + (checkout) => checkout.retiredWorkspaceId ?? [], + ); + if (retired.length > 0) { + tx.delete(workspaces).where(inArray(workspaces.id, retired)).run(); + } }); } diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index 378a9a35975..da0fd1c6647 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -118,10 +118,11 @@ export const protectedProcedure = baseProcedure.use(async ({ ctx, next }) => { /** * For procedures that only make sense on a machine someone owns. * - * A cloud workspace's sandbox is one repo, one project, one workspace, fixed - * at provision: the checkout *is* the workspace and there is no base repo to - * branch from. Adding a project, removing the only one, or cutting a worktree - * inside it produces state the cloud side can neither see nor clean up. The + * A cloud workspace's sandbox is one workspace over the repositories it was + * provisioned with, a project each: every checkout *is* its repo and there is + * no base repo to branch from. Adding a project, removing one, or cutting a + * worktree inside it produces state the cloud side can neither see nor clean + * up. The * check lives here rather than in each caller because the callers are * whatever runs in the sandbox — an agent, the CLI, a shell — not just our * own UI. @@ -135,7 +136,7 @@ export const machineOnlyProcedure = protectedProcedure.use( if (process.env.SUPERSET_HOST_RUN_MODE === "sandbox") { throw new TRPCError({ code: "PRECONDITION_FAILED", - message: `${path} is not available in a cloud workspace: its sandbox holds exactly one project and one workspace.`, + message: `${path} is not available in a cloud workspace: its sandbox holds exactly one workspace, over the repositories it was provisioned with.`, }); } return next({ ctx }); diff --git a/packages/host-service/test/integration/local-workspaces-edge.integration.test.ts b/packages/host-service/test/integration/local-workspaces-edge.integration.test.ts index e495facdad6..b7d87a2b253 100644 --- a/packages/host-service/test/integration/local-workspaces-edge.integration.test.ts +++ b/packages/host-service/test/integration/local-workspaces-edge.integration.test.ts @@ -703,20 +703,21 @@ describe("local workspaces: sessions and sandbox seed", () => { runSandboxSelfSeed(host.db, identity); runSandboxSelfSeed(host.db, identity); const rows = host.db.select().from(workspaces).all(); - // One local workspace per checkout: the primary under the cloud - // workspace's id, the sibling under an id derived from it. - expect(rows).toHaveLength(2); + // One workspace over every checkout, exactly as a local multi-repo + // workspace is stored. + expect(rows).toHaveLength(1); expect(rows.find((row) => row.id === identity.workspaceId)).toMatchObject({ type: "local", worktreePath: "/workspace/repo", + rootPath: "/workspace", name: "sandbox ws", }); expect( - rows.find( + rows.some( (row) => row.id === sandboxRepositoryWorkspaceId(identity.workspaceId, "docs"), ), - ).toMatchObject({ type: "local", worktreePath: "/workspace/docs" }); + ).toBe(false); // A sandbox's only workspace can still be retired record-only. const preview = await host.trpc.workspaceCleanup.inspect.query({ workspaceId: identity.workspaceId, diff --git a/packages/host-service/test/integration/sandbox-self-seed.integration.test.ts b/packages/host-service/test/integration/sandbox-self-seed.integration.test.ts new file mode 100644 index 00000000000..c83a2d2fa50 --- /dev/null +++ b/packages/host-service/test/integration/sandbox-self-seed.integration.test.ts @@ -0,0 +1,312 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import type { SandboxRepository } from "@superset/shared/sandbox-contract"; +import { eq } from "drizzle-orm"; +import { projects, workspaceRepos, workspaces } from "../../src/db/schema"; +import { + readSandboxIdentity, + runSandboxSelfSeed, + type SandboxIdentity, + sandboxRepositoryWorkspaceId, +} from "../../src/runtime/sandbox-self-seed/sandbox-self-seed"; +import { + findWorkspaceRepo, + listWorkspaceRepos, +} from "../../src/workspaces/workspace-repos"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +const ROOT = "/workspace"; + +function identityFor(repositories: SandboxRepository[]): SandboxIdentity { + const identity = readSandboxIdentity({ + SUPERSET_SANDBOX_WORKSPACE_ID: randomUUID(), + SUPERSET_SANDBOX_WORKSPACE_PATH: ROOT, + SUPERSET_SANDBOX_REPOSITORIES: JSON.stringify(repositories), + HOST_DB_PATH: "/var/lib/superset/host.db", + } as NodeJS.ProcessEnv); + if (!identity) throw new Error("test identity did not parse"); + return identity; +} + +const SOLO: SandboxRepository[] = [ + { url: "https://github.com/acme/repo.git", branch: "feature/x", path: "." }, +]; + +const PAIR: SandboxRepository[] = [ + { + url: "https://github.com/acme/api.git", + branch: "feature/x", + baseBranch: "main", + path: "api", + hooks: true, + }, + { + url: "https://github.com/acme/docs.git", + branch: "feature/x", + baseBranch: "trunk", + path: "docs", + }, +]; + +function repoRows(host: TestHost, workspaceId: string) { + return host.db + .select() + .from(workspaceRepos) + .where(eq(workspaceRepos.workspaceId, workspaceId)) + .all() + .sort((a, b) => a.position - b.position); +} + +/** What a build before this one wrote: a project and a workspace per repository. */ +function seedTheOldWay(host: TestHost, identity: SandboxIdentity): string[] { + const now = Date.now(); + return identity.repositories.map((repo, index) => { + const projectId = randomUUID(); + const worktreePath = `${ROOT}/${repo.path}`; + host.db + .insert(projects) + .values({ + id: projectId, + repoPath: worktreePath, + name: index === 0 ? identity.projectName : repo.path, + createdAt: now, + updatedAt: now, + }) + .run(); + host.db + .insert(workspaces) + .values({ + id: + index === 0 + ? identity.workspaceId + : sandboxRepositoryWorkspaceId(identity.workspaceId, repo.path), + projectId, + worktreePath, + branch: repo.branch, + name: index === 0 ? identity.workspaceName : repo.path, + type: "local", + createdAt: now, + updatedAt: now, + }) + .run(); + return projectId; + }); +} + +let host: TestHost | null = null; +let runMode: string | undefined; + +// The startup sweeps never run on a box, and one of them materializes the +// very rows these tests assert the seed does or does not write. +beforeAll(() => { + runMode = process.env.SUPERSET_HOST_RUN_MODE; + process.env.SUPERSET_HOST_RUN_MODE = "sandbox"; +}); + +afterAll(() => { + if (runMode === undefined) delete process.env.SUPERSET_HOST_RUN_MODE; + else process.env.SUPERSET_HOST_RUN_MODE = runMode; +}); + +afterEach(async () => { + await host?.dispose(); + host = null; +}); + +describe("sandbox self-seed", () => { + test("a single-repository box is stored exactly as it was before", async () => { + host = await createTestHost(); + const identity = identityFor(SOLO); + + runSandboxSelfSeed(host.db, identity); + runSandboxSelfSeed(host.db, identity); + + const rows = host.db.select().from(workspaces).all(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: identity.workspaceId, + worktreePath: ROOT, + rootPath: null, + branch: "feature/x", + name: identity.workspaceName, + type: "local", + }); + expect(repoRows(host, identity.workspaceId)).toHaveLength(0); + + const projectRows = host.db.select().from(projects).all(); + expect(projectRows).toHaveLength(1); + expect(projectRows[0]).toMatchObject({ + repoPath: ROOT, + name: identity.projectName, + }); + + // The synthesized primary is what every checkout-aware surface reads. + const repos = listWorkspaceRepos(host.db, identity.workspaceId); + expect(repos).toHaveLength(1); + expect(repos[0]?.worktreePath).toBe(ROOT); + }); + + test("several repositories become one workspace over one row per checkout", async () => { + host = await createTestHost(); + const identity = identityFor(PAIR); + + runSandboxSelfSeed(host.db, identity); + + const rows = host.db.select().from(workspaces).all(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: identity.workspaceId, + worktreePath: `${ROOT}/api`, + rootPath: ROOT, + branch: "feature/x", + type: "local", + }); + + const repos = repoRows(host, identity.workspaceId); + expect( + repos.map((repo) => ({ + position: repo.position, + folder: repo.folder, + worktreePath: repo.worktreePath, + branch: repo.branch, + baseBranch: repo.baseBranch, + })), + ).toEqual([ + { + position: 0, + folder: "api", + worktreePath: `${ROOT}/api`, + branch: "feature/x", + baseBranch: "main", + }, + { + position: 1, + folder: "docs", + worktreePath: `${ROOT}/docs`, + branch: "feature/x", + baseBranch: "trunk", + }, + ]); + + // One project per repository, each pointing at its own checkout. + const projectRows = host.db + .select() + .from(projects) + .all() + .sort((a, b) => a.repoPath.localeCompare(b.repoPath)); + expect(projectRows.map((project) => project.repoPath)).toEqual([ + `${ROOT}/api`, + `${ROOT}/docs`, + ]); + expect(repos[0]?.projectId).toBe(rows[0]?.projectId ?? ""); + expect(repos[1]?.projectId).not.toBe(repos[0]?.projectId ?? ""); + + // The `repo` argument on git.* and the picker resolve by folder. + expect( + findWorkspaceRepo(host.db, identity.workspaceId, "docs")?.worktreePath, + ).toBe(`${ROOT}/docs`); + expect(findWorkspaceRepo(host.db, identity.workspaceId, null)?.folder).toBe( + "api", + ); + + const listed = await host.trpc.workspace.list.query(); + const workspace = listed.find((row) => row.id === identity.workspaceId); + expect(workspace?.rootPath).toBe(ROOT); + expect(workspace?.repos.map((repo) => repo.folder)).toEqual([ + "api", + "docs", + ]); + expect(workspace?.repos.map((repo) => repo.base)).toEqual([ + "main", + "trunk", + ]); + }); + + test("a restart leaves every row it already wrote alone", async () => { + host = await createTestHost(); + const identity = identityFor(PAIR); + + runSandboxSelfSeed(host.db, identity); + const before = repoRows(host, identity.workspaceId); + const projectsBefore = host.db.select().from(projects).all(); + + runSandboxSelfSeed(host.db, identity); + runSandboxSelfSeed(host.db, identity); + + expect(repoRows(host, identity.workspaceId)).toEqual(before); + expect(host.db.select().from(projects).all()).toEqual(projectsBefore); + expect(host.db.select().from(workspaces).all()).toHaveLength(1); + }); + + test("a box seeded one workspace per repository converges", async () => { + host = await createTestHost(); + const identity = identityFor(PAIR); + const [primaryProjectId, docsProjectId] = seedTheOldWay(host, identity); + + runSandboxSelfSeed(host.db, identity); + + const rows = host.db.select().from(workspaces).all(); + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(identity.workspaceId); + expect(rows[0]?.rootPath).toBe(ROOT); + expect( + host.db + .select() + .from(workspaces) + .where( + eq( + workspaces.id, + sandboxRepositoryWorkspaceId(identity.workspaceId, "docs"), + ), + ) + .all(), + ).toHaveLength(0); + + // The projects the old shape created are reused, not duplicated: their + // ids are what agent and terminal state on the box already reference. + expect(host.db.select().from(projects).all()).toHaveLength(2); + const repos = repoRows(host, identity.workspaceId); + expect(repos.map((repo) => repo.projectId)).toEqual([ + primaryProjectId ?? "", + docsProjectId ?? "", + ]); + expect(repos.map((repo) => repo.folder)).toEqual(["api", "docs"]); + }); + + test("a repository list that changed order is rewritten, not merged", async () => { + host = await createTestHost(); + const identity = identityFor(PAIR); + runSandboxSelfSeed(host.db, identity); + + const reordered: SandboxIdentity = { + ...identity, + repositories: [ + PAIR[1] as SandboxRepository, + PAIR[0] as SandboxRepository, + ], + worktreePath: `${ROOT}/docs`, + hooksPath: `${ROOT}/docs`, + }; + runSandboxSelfSeed(host.db, reordered); + + const repos = repoRows(host, identity.workspaceId); + expect(repos.map((repo) => repo.folder)).toEqual(["docs", "api"]); + // Position 0 is the workspace's own checkout — the column half the app + // reads instead of the row follows it. + const row = host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, identity.workspaceId)) + .get(); + expect(row?.worktreePath).toBe(`${ROOT}/docs`); + expect(row?.projectId).toBe(repos[0]?.projectId ?? ""); + expect(host.db.select().from(projects).all()).toHaveLength(2); + }); +}); From ee86a5e483ec298057ad493b14becc0fb336234b Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 11:40:15 +0530 Subject: [PATCH 12/14] fix(host-service): keep a workspace's primary checkout keyed by its own id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keying every checkout's watch state by `workspaceId + worktreePath` moved the primary's entry out from under the id that `watched`, `ignoredDirs` and their readers have always addressed it by, so the lazy-registration coverage for #6729 stopped finding it — six tests, timing out rather than failing an assertion, which is what hid it behind the debounce windows. Only extra checkouts need a composite key. The primary keeps the workspace's id, so the map reads exactly as it did before a workspace could hold more than one checkout. The one genuine seam change stays: the ignore-set swap is per checkout (`refreshCheckoutWatcherIgnores`), since the native watcher it re-derives is attached at the checkout, not at the container that `resolveWorkspaceRoot` returns for a multi-repo workspace. --- .../host-service/src/events/git-watcher.ts | 35 ++++++++++++------- ...cher-lazy-registration.integration.test.ts | 2 +- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/host-service/src/events/git-watcher.ts b/packages/host-service/src/events/git-watcher.ts index faf06094416..8a834dd24f5 100644 --- a/packages/host-service/src/events/git-watcher.ts +++ b/packages/host-service/src/events/git-watcher.ts @@ -202,6 +202,7 @@ interface PendingBatch { } interface WatchedWorkspace { + key: string; workspaceId: string; worktreePath: string; gitDir: string; @@ -338,9 +339,17 @@ export class GitWatcher { } } - /** Watch state is per checkout; everything downstream of an event is per workspace. */ - private watchKey(workspaceId: string, worktreePath: string): string { - return `${workspaceId}\u0000${worktreePath}`; + /** + * Watch state is per checkout; everything downstream of an event is per + * workspace. The primary keeps the workspace's own id, which is the + * identity every reader of this map already assumes. + */ + private watchKey( + workspaceId: string, + worktreePath: string, + isPrimary: boolean, + ): string { + return isPrimary ? workspaceId : `${workspaceId}\u0000${worktreePath}`; } private isWatched(workspaceId: string): boolean { @@ -472,6 +481,7 @@ export class GitWatcher { * git; `force` bypasses the min-interval for the initial load. */ private refreshIgnoredDirs( + key: string, workspaceId: string, worktreePath: string, force = false, @@ -480,7 +490,6 @@ export class GitWatcher { // started them: unwatchWorkspace() (or unwatch-then-rewatch) mid-refresh // would otherwise re-create ignore state and, on a swap, schedule a // git:changed for a workspace nobody watches any more. - const key = this.watchKey(workspaceId, worktreePath); const entry = this.watched.get(key); if (!entry) return; const stillCurrent = () => !this.closed && this.watched.get(key) === entry; @@ -532,7 +541,7 @@ export class GitWatcher { // is stale and the emit that flagged it was swallowed by the // `refreshing` guard — run once more. if (state.rulesChanged && stillCurrent()) { - this.refreshIgnoredDirs(workspaceId, worktreePath, true); + this.refreshIgnoredDirs(key, workspaceId, worktreePath, true); } }); } @@ -587,7 +596,7 @@ export class GitWatcher { // set so the follow-up churn stops emitting. for (const entry of this.watched.values()) { if (entry.workspaceId !== workspaceId) continue; - this.refreshIgnoredDirs(workspaceId, entry.worktreePath); + this.refreshIgnoredDirs(entry.key, workspaceId, entry.worktreePath); } } @@ -740,7 +749,8 @@ export class GitWatcher { for (const id of this.interest.keys()) { if (!worktreePathById.has(id)) continue; for (const [index, worktreePath] of this.checkoutsOf(id).entries()) { - if (this.watched.has(this.watchKey(id, worktreePath))) continue; + if (this.watched.has(this.watchKey(id, worktreePath, index === 0))) + continue; await this.attachWatcher(id, worktreePath, index === 0); } } @@ -776,7 +786,7 @@ export class GitWatcher { return; } - const key = this.watchKey(workspaceId, worktreePath); + const key = this.watchKey(workspaceId, worktreePath, isPrimary); if (this.closed || this.watched.has(key)) return; // Start the worktree watch first so we have a dispose handle to capture @@ -785,6 +795,7 @@ export class GitWatcher { const disposeWorktreeWatch = this.startWorktreeWatch( workspaceId, worktreePath, + key, isPrimary, () => { if ( @@ -834,6 +845,7 @@ export class GitWatcher { const wasWatched = this.isWatched(workspaceId); this.watched.set(key, { + key, workspaceId, worktreePath, gitDir, @@ -841,7 +853,7 @@ export class GitWatcher { disposeWorktreeWatch, }); if (!wasWatched) this.notifyWatchState(workspaceId, true); - this.refreshIgnoredDirs(workspaceId, worktreePath, true); + this.refreshIgnoredDirs(key, workspaceId, worktreePath, true); // A change can land in the gap between watchWorkspace() and this line // (the DB lookup + `git rev-parse` above are async) and go unobserved — @@ -862,6 +874,7 @@ export class GitWatcher { private startWorktreeWatch( workspaceId: string, worktreePath: string, + watchKey: string, isPrimary: boolean, onFailure: () => void = () => {}, ): () => void { @@ -913,9 +926,7 @@ export class GitWatcher { continue; } - const ignoredState = this.getOrCreateIgnoredDirsState( - this.watchKey(workspaceId, worktreePath), - ); + const ignoredState = this.getOrCreateIgnoredDirsState(watchKey); const filtered = filterGitIgnoredEvents( next.value.events, worktreePath, diff --git a/packages/host-service/test/integration/git-watcher-lazy-registration.integration.test.ts b/packages/host-service/test/integration/git-watcher-lazy-registration.integration.test.ts index 251e636136b..631ae176de3 100644 --- a/packages/host-service/test/integration/git-watcher-lazy-registration.integration.test.ts +++ b/packages/host-service/test/integration/git-watcher-lazy-registration.integration.test.ts @@ -367,7 +367,7 @@ describe("GitWatcher lazy registration (regression coverage for #6729)", () => { // watcher's ignore set) lands on the swap path exactly when interest is // released. Before the guard, the continuation scheduled a git:changed // for a workspace nothing watched any more. - scenario.filesystem.refreshWatcherIgnores = async () => { + scenario.filesystem.refreshCheckoutWatcherIgnores = async () => { scenario.gitWatcher.unwatchWorkspace(id); return true; }; From e7093fe8293566ad865299b05b3e6fe2631df309 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 17:13:31 +0530 Subject: [PATCH 13/14] feat(desktop): give the folder picker its repositories' avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder switcher in the workspace sidebar was a folder glyph and a line of text, which reads as a path control rather than as the repository switcher it is. Each row and the trigger now carry the checkout's GitHub avatar — the same thumbnail the sidebar and the project pickers use — falling back to the letter tile for a checkout with no remote. A project assembled out of local folders has no remote of its own, so its avatar now falls through to its source folders before giving up on the letter tile: the sidebar, the settings list and the Project page all resolve it the same way. --- .../useGroupedProjects/applyProjectGroups.ts | 23 ++++++++- .../host-projects/useGroupedProjects/index.ts | 1 + .../sidebarProjectGroups.test.ts | 49 +++++++++++++++++++ .../RepoFolderPicker/RepoFolderPicker.tsx | 24 +++++++-- .../RepoFolderPicker/repoIconUrl.test.ts | 15 ++++++ .../RepoFolderPicker/repoIconUrl.ts | 11 +++++ .../buildProjectSettingsRows.ts | 6 +++ .../ProjectGroupSettings.tsx | 13 +++-- 8 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/repoIconUrl.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/repoIconUrl.ts diff --git a/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/applyProjectGroups.ts b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/applyProjectGroups.ts index 36936f1c508..f129daf4e76 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/applyProjectGroups.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/applyProjectGroups.ts @@ -28,11 +28,30 @@ export function resolvePrimaryProjectId( return primary?.projectId ?? projectId; } +/** + * A project assembled out of local folders has no remote of its own to draw an + * avatar from, so it falls through to its source folders rather than to the + * letter tile. + */ +export function inheritedProjectIconUrl( + group: HostProjectGroup, + iconUrlByProjectId: Map, +): string | null { + for (const member of group.members) { + const iconUrl = iconUrlByProjectId.get(member.projectId); + if (iconUrl) return iconUrl; + } + return null; +} + export function applyProjectGroups< - Project extends { id: string; name: string }, + Project extends { id: string; name: string; iconUrl?: string | null }, >(projects: Project[], groups: HostProjectGroup[]): GroupedProject[] { const groupByPrimaryProjectId = indexProjectGroupsByPrimaryProjectId(groups); const sourceFolderOnlyProjectIds = collectSourceFolderOnlyProjectIds(groups); + const iconUrlByProjectId = new Map( + projects.map((project) => [project.id, project.iconUrl]), + ); return projects.flatMap((project): GroupedProject[] => { if (sourceFolderOnlyProjectIds.has(project.id)) return []; @@ -42,6 +61,8 @@ export function applyProjectGroups< { ...project, name: group.name, + iconUrl: + project.iconUrl ?? inheritedProjectIconUrl(group, iconUrlByProjectId), groupId: group.id, repoCount: group.members.length, }, diff --git a/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/index.ts b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/index.ts index ec6cc963fed..2ad116a5f08 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/index.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/useGroupedProjects/index.ts @@ -1,6 +1,7 @@ export { applyProjectGroups, type GroupedProject, + inheritedProjectIconUrl, resolvePrimaryProjectId, } from "./applyProjectGroups"; export { useGroupedProjectId, useGroupedProjects } from "./useGroupedProjects"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/sidebarProjectGroups.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/sidebarProjectGroups.test.ts index 24ea55992f9..c1aff627257 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/sidebarProjectGroups.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/sidebarProjectGroups.test.ts @@ -139,3 +139,52 @@ describe("a project over several repositories", () => { expect(grouped.map((project) => project.name)).toEqual(["Platform", "web"]); }); }); + +describe("a project whose own folder has no remote", () => { + it("takes the avatar of the first source folder that has one", () => { + const grouped = applyProjectGroups( + [ + makeProject({ id: "project-town", name: "town", iconUrl: null }), + makeProject({ + id: "project-roster", + name: "roster", + iconUrl: "https://github.com/acme.png?size=64", + }), + ], + [ + makeGroup("group-samplee", "samplee", [ + "project-town", + "project-roster", + ]), + ], + ); + + expect(grouped).toHaveLength(1); + expect(grouped[0]?.iconUrl).toBe("https://github.com/acme.png?size=64"); + }); + + it("keeps its own avatar when it has one", () => { + const grouped = applyProjectGroups( + [ + makeProject({ + id: "project-town", + name: "town", + iconUrl: "https://github.com/town.png?size=64", + }), + makeProject({ + id: "project-roster", + name: "roster", + iconUrl: "https://github.com/acme.png?size=64", + }), + ], + [ + makeGroup("group-samplee", "samplee", [ + "project-town", + "project-roster", + ]), + ], + ); + + expect(grouped[0]?.iconUrl).toBe("https://github.com/town.png?size=64"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.tsx index 191dde7bd00..78c81baebcb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/RepoFolderPicker.tsx @@ -2,11 +2,21 @@ import { useLingui } from "@lingui/react/macro"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { ScrollArea } from "@superset/ui/scroll-area"; import { cn } from "@superset/ui/utils"; -import { Check, ChevronDown, Folder } from "lucide-react"; +import { Check, ChevronDown } from "lucide-react"; import { useState } from "react"; +import { resolveProjectIconUrl } from "renderer/hooks/host-projects/resolveProjectIconUrl"; import { useWorkspaceRepos } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceRepos"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; import { useRepoDirtyCounts } from "../../../../hooks/useRepoDirtyCounts"; +/** `repository` is `owner/name` when the checkout has a parsed GitHub remote. */ +function repoIconUrl(repository: string | null): string | null { + return resolveProjectIconUrl({ + icon: null, + repoOwner: repository?.split("/")[0] ?? null, + }); +} + interface RepoFolderPickerProps { workspaceId: string; } @@ -33,7 +43,11 @@ export function RepoFolderPicker({ workspaceId }: RepoFolderPickerProps) { aria-label={t({ message: "Change folder" })} className="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md px-1.5 text-xs text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - + {selected.folder} @@ -60,7 +74,11 @@ export function RepoFolderPicker({ workspaceId }: RepoFolderPickerProps) { setOpen(false); }} > - + {repo.folder} {repo.repository && repo.repository !== repo.folder && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/repoIconUrl.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/repoIconUrl.test.ts new file mode 100644 index 00000000000..a60d85dd53f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/repoIconUrl.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "bun:test"; +import { repoIconUrl } from "./repoIconUrl"; + +describe("a checkout's avatar in the folder picker", () => { + it("is the GitHub owner's, so a folder reads as the repository it holds", () => { + expect(repoIconUrl("acme/roster")).toBe( + "https://github.com/acme.png?size=64", + ); + }); + + it("is none for a checkout with no GitHub remote", () => { + expect(repoIconUrl(null)).toBeNull(); + expect(repoIconUrl("roster")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/repoIconUrl.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/repoIconUrl.ts new file mode 100644 index 00000000000..e89ccf0b943 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/RepoFolderPicker/repoIconUrl.ts @@ -0,0 +1,11 @@ +import { resolveProjectIconUrl } from "renderer/hooks/host-projects/resolveProjectIconUrl"; + +/** + * The avatar for one checkout of a workspace. `repository` is `owner/name` + * when the checkout has a parsed GitHub remote, and the folder name when it + * has none — which owns no avatar, so the picker falls back to a letter tile. + */ +export function repoIconUrl(repository: string | null): string | null { + const owner = repository?.includes("/") ? repository.split("/")[0] : null; + return resolveProjectIconUrl({ icon: null, repoOwner: owner ?? null }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.ts index ae31de19034..7da3424aa2b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/buildProjectSettingsRows.ts @@ -1,3 +1,4 @@ +import { inheritedProjectIconUrl } from "renderer/hooks/host-projects/useGroupedProjects"; import { collectSourceFolderOnlyProjectIds, type HostProjectGroup, @@ -29,6 +30,9 @@ export function buildProjectSettingsRows( const projectsById = new Map( projects.map((project) => [project.id, project]), ); + const iconUrlByProjectId = new Map( + projects.map((project) => [project.id, project.iconUrl]), + ); return projects.flatMap((project): ProjectSettingsRow[] => { if (sourceFolderOnlyProjectIds.has(project.id)) return []; @@ -51,6 +55,8 @@ export function buildProjectSettingsRows( { ...project, name: group.name, + iconUrl: + project.iconUrl ?? inheritedProjectIconUrl(group, iconUrlByProjectId), kind: "project", groupId: group.id, parentGroupId: null, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/ProjectGroupSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/ProjectGroupSettings.tsx index 385c3345fd7..a42e328a122 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/ProjectGroupSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/group/$groupId/components/ProjectGroupSettings/ProjectGroupSettings.tsx @@ -79,10 +79,15 @@ export function ProjectGroupSettings({ groupId }: ProjectGroupSettingsProps) { } const projectIcon = group.icon ?? hostProject?.icon ?? null; - const iconUrl = resolveProjectIconUrl({ - icon: projectIcon, - repoOwner: primaryProject?.repoOwner ?? null, - }); + // A project assembled out of local folders has no remote of its own, so the + // avatar comes from whichever source folder has one. + const repoOwner = + primaryProject?.repoOwner ?? + group.members + .map((member) => projectsById.get(member.projectId)?.repoOwner) + .find(Boolean) ?? + null; + const iconUrl = resolveProjectIconUrl({ icon: projectIcon, repoOwner }); return (
From f22770d9dda438784438b97be3fb8b27bcde382e Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 18:07:59 +0530 Subject: [PATCH 14/14] chore(i18n): restore the branch's translations after the rebase onto main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase conflicted on every catalog — main and this branch each added messages — and each replayed commit took main's side, which dropped the translations this branch had written. Regenerated from source and refilled from the pre-rebase tip, so both sides' messages are present and translated. --- packages/i18n/locales/cs/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/de/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/en/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/es/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/fr/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/id/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/it/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/ja/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/ko/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/nl/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/pl/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/pt-BR/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/ru/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/tr/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/vi/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/zh-CN/messages.po | 129 ++++++++++++++++++++++++ packages/i18n/locales/zh-TW/messages.po | 129 ++++++++++++++++++++++++ 17 files changed, 2193 insertions(+) diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 7931c22c72c..ca38e375fcb 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· aktualizováno {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "„{0}“ se nepodařilo přidat: {1}. Dříve přidané zdrojové složky byly zachovány — odeberte ji nebo problém opravte a vytvořte projekt znovu." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "„{0}“ smazáno" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} z {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# zdrojová složka} few {# zdrojové složky} many {# zdrojové složky} other {# zdrojových složek}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Opakuji # automatizaci} few {Opakuji # automatizace} many {Opakuji # automatizace} other {Opakuji # automatizací}}" @@ -1381,6 +1389,7 @@ msgstr "Akce" msgid "Actions" msgstr "Akce" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Akce pro {0}" @@ -1438,6 +1447,9 @@ msgstr "Přidejte projekt a uvidíte GitHub issues." msgid "Add a project to see pull requests." msgstr "Přidejte projekt a uvidíte pull requesty." +msgid "Add a repository folder" +msgstr "Přidat složku repozitáře" + msgid "Add account" msgstr "Přidat účet" @@ -1492,6 +1504,9 @@ msgstr "Přidat soubory, aplikace a další" msgid "Add files, apps, and more (@)" msgstr "Přidat soubory, aplikace a další (@)" +msgid "Add folder" +msgstr "Přidat složku" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Přidat issue #{0} do pracovního prostoru" @@ -1535,6 +1550,12 @@ msgstr "Doplň testy pro nejrizikovější netestovaný kód…" msgid "Add tests where they're missing" msgstr "Doplnit testy tam, kde chybí" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Přidejte repozitáře, se kterými tento projekt pracuje, jako zdrojové složky. Každý pracovní prostor načte všechny." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Přidejte repozitáře, se kterými tento projekt pracuje. První z nich je hlavní." + msgid "Add to Dictionary" msgstr "Přidat do slovníku" @@ -1547,6 +1568,10 @@ msgstr "Přidat do pracovního prostoru" msgid "Add your own terminal agent to this device." msgstr "Přidejte na toto zařízení vlastního terminálového agenta." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} byl přidán do tohoto projektu" + msgid "Added {added} files to {where}" msgstr "Přidáno {added} souborů do {where}" @@ -1973,6 +1998,9 @@ msgstr "Anonymní montér" msgid "another device" msgstr "jiné zařízení" +msgid "Another folder in this project already has that name." +msgstr "Jiná složka v tomto projektu už tento název má." + msgid "Answered" msgstr "Zodpovězeno" @@ -2964,6 +2992,9 @@ msgstr "Řetězec úvah" msgid "Change avatar" msgstr "Změnit avatar" +msgid "Change folder" +msgstr "Změnit složku" + msgid "Change location" msgstr "Změnit umístění" @@ -2998,6 +3029,9 @@ msgstr "Změnit umístění worktree" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Míra selhání změn na úrovni lidské éry nebo pod ní po dvě čtvrtletí po sobě." +msgid "Changed files" +msgstr "Změněné soubory" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Naklonujte repozitář nebo naimportujte existující složku na hostu." msgid "Clone, push, and create PRs." msgstr "Klonujte, odesílejte změny a vytvářejte PR." +msgid "Cloned into each new workspace" +msgstr "Klonuje se do každého nového pracovního prostoru" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Naklonováno do {0}" @@ -4244,6 +4281,10 @@ msgstr "Projekt se nepodařilo vytvořit" msgid "Could not create project. Please try a different name or check the logs." msgstr "Projekt se nepodařilo vytvořit. Zkuste jiný název nebo se podívejte do logů." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Projekt se nepodařilo vytvořit: {0}" + msgid "Could not delete account" msgstr "Účet se nepodařilo smazat" @@ -4638,6 +4679,12 @@ msgstr "Vytvořit PR" msgid "Create PR failed: {0}" msgstr "Vytvoření PR se nezdařilo: {0}" +msgid "Create project" +msgstr "Vytvořit projekt" + +msgid "Create project with multiple folders" +msgstr "Vytvořit projekt s více složkami" + msgid "Create pull request" msgstr "Vytvořit pull request" @@ -4757,6 +4804,9 @@ msgstr "Vytvářím worktree" msgid "Creating..." msgstr "Vytvářím..." +msgid "Creating…" +msgstr "Vytvářím…" + msgid "Creative Manager, Valid" msgstr "Kreativní manažer, Valid" @@ -4987,6 +5037,9 @@ msgstr "Smazat „{title}“?" msgid "Delete {0}?" msgstr "Smazat {0}?" +msgid "Delete {groupName}?" +msgstr "Smazat {groupName}?" + msgid "Delete {name}?" msgstr "Smazat {name}?" @@ -5625,6 +5678,9 @@ msgstr "Každý agent běží ve vlastním izolovaném git worktree. Žádné ko msgid "Each attachment is at most {0} MB." msgstr "Každá příloha může mít nejvýše {0} MB." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Každá složka spouští vlastní příkazy pro setup, teardown a spuštění, a to v tomto pořadí." + msgid "Each in its own new tab" msgstr "Každý ve vlastní nové kartě" @@ -6787,6 +6843,9 @@ msgstr "Složka" msgid "Folder links" msgstr "Odkazy na složky" +msgid "Folder name" +msgstr "Název složky" + msgid "Folder view" msgstr "Zobrazení složek" @@ -7954,6 +8013,9 @@ msgstr "Položka" msgid "Its own branch and copy of the files" msgstr "Vlastní větev a vlastní kopie souborů" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Jeho zdrojové složky zůstanou v tomto zařízení jako samostatné projekty." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Její pracovní prostory a terminály nebudou dostupné, dokud se nerestartuje — použijte nabídku Superset na liště > Host Service > Restart." @@ -8474,6 +8536,9 @@ msgstr "Načítám rozdíly…" msgid "Loading files..." msgstr "Načítám soubory..." +msgid "Loading folders…" +msgstr "Načítání složek…" + msgid "Loading fonts..." msgstr "Načítám fonty..." @@ -8630,6 +8695,9 @@ msgstr "Nastavit jako výchozí" msgid "Make default — launch new terminals and agents on this account." msgstr "Nastavit jako výchozí — nové terminály a agenty spouštět na tomto účtu." +msgid "Make primary" +msgstr "Nastavit jako hlavní" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Ujistěte se, že je zařízení probuzené, online a běží na něm Superset." @@ -9083,6 +9151,9 @@ msgstr "Můj agent" msgid "My Tasks" msgstr "Moje úkoly" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Záhadný strojník" @@ -9310,6 +9381,12 @@ msgstr "noví tvůrci pracovních prostorů" msgid "New workspace in {name}" msgstr "Nový pracovní prostor v {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Nové pracovní prostory už jeho složky nebudou načítat společně. Stávající pracovní prostory si své kopie ponechají a z disku se nesmaže žádný repozitář." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Nové pracovní prostory už tento repozitář nebudou načítat. Stávající pracovní prostory si ho ponechají a z disku se nic nesmaže." + msgid "New-user activation" msgstr "Aktivace nových uživatelů" @@ -11095,6 +11172,9 @@ msgstr "Vyberte nadřazený adresář" msgid "Pick a project location" msgstr "Vyberte umístění projektu" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Vyberte na {hostName} repozitář, který chcete přidat do tohoto projektu." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Vyberte zvuk nebo přidejte vlastní. Vlastní zvuk podporuje .mp3, .wav a .ogg." @@ -11392,6 +11472,10 @@ msgstr "Cena závisí na vašem týmu a způsobu použití. Ozvěte se nám a se msgid "Primary" msgstr "Primární" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Hlavní" + msgid "Primary Key" msgstr "Primární klíč" @@ -12011,16 +12095,24 @@ msgstr "Vzdálený pracovní prostor — zařízení offline" msgid "Remove" msgstr "Odebrat" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Odebrat {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Odebrat {0} z tohoto projektu?" + msgid "Remove {label}?" msgstr "Odebrat {label}?" msgid "Remove attachment" msgstr "Odebrat přílohu" +msgid "Remove folder" +msgstr "Odebrat složku" + msgid "Remove from Queue" msgstr "Odebrat z fronty" @@ -12112,6 +12204,9 @@ msgstr "Přejmenování selhalo" msgid "Rename failed: {0}" msgstr "Přejmenování selhalo: {0}" +msgid "Rename folder" +msgstr "Přejmenovat složku" + msgid "Rename group" msgstr "Přejmenovat skupinu" @@ -12219,6 +12314,9 @@ msgstr "Repozitáře" msgid "Repositories accessible through the GitHub App installation." msgstr "Repozitáře dostupné přes instalaci GitHub App." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repozitáře načtené do každého pracovního prostoru tohoto projektu. Hlavní je ten, který používají nástroje pro jeden repozitář." + msgid "Repositories refreshed" msgstr "Repozitáře obnoveny" @@ -13144,6 +13242,12 @@ msgstr "Vyberte projekt a uvidíte pull requesty." msgid "Select a pull request to preview it here." msgstr "Vyberte pull request a zobrazí se tady jeho náhled." +msgid "Select a repository to add" +msgstr "Vyberte repozitář, který chcete přidat" + +msgid "Select a source folder" +msgstr "Vyberte zdrojovou složku" + msgid "Select a team" msgstr "Vyberte tým" @@ -13993,6 +14097,13 @@ msgstr "Zdroj" msgid "Source Available" msgstr "Dostupný zdrojový kód" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Zdrojová složka projektu {0}" + +msgid "Source folders" +msgstr "Zdrojové složky" + msgid "Source path (not an import)" msgstr "Cesta ke zdroji (ne import)" @@ -14939,6 +15050,9 @@ msgstr "Tento koncový bod se nepodařilo přeložit." msgid "That endpoint is not allowed." msgstr "Tento koncový bod není povolen." +msgid "That folder is already in the list" +msgstr "Tato složka už je v seznamu" + msgid "That handle is taken." msgstr "Tato přezdívka je obsazená." @@ -15153,6 +15267,9 @@ msgstr "Stránka, kterou hledáte, neexistuje." msgid "The people behind Superset." msgstr "Lidé za Supersetem." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "Hlavní složku nelze odebrat. Nejprve nastavte jako hlavní jinou složku." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Checkout projektu tak, jak je: soubory a větev jsou sdílené" @@ -15402,6 +15519,9 @@ msgstr "Trvá to déle než obvykle. Relace už možná je připravená — nač msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Trvá to déle než obvykle. Pracovní prostor už možná je připravený — načtení znovu ho může zachytit." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Toto je adresář, do kterého se repozitář načítá uvnitř každého pracovního prostoru." + msgid "This issue link is invalid." msgstr "Tento odkaz na issue je neplatný." @@ -15463,6 +15583,9 @@ msgstr "Tento port se stále používá" msgid "This profile is busy right now" msgstr "Tento profil je právě přetížený" +msgid "This project has no folders on {hostName} yet." +msgstr "Tento projekt zatím nemá na {hostName} žádné složky." + msgid "This project is no longer available on your devices." msgstr "Tento projekt už na vašich zařízeních není dostupný." @@ -16290,6 +16413,9 @@ msgstr "Použít pro nové agenty" msgid "Use global default" msgstr "Použít globální výchozí" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Použijte písmena, číslice, tečky, pomlčky nebo podtržítka a začněte písmenem nebo číslicí." + msgid "Use Superset on mobile" msgstr "Používejte Superset na mobilu" @@ -16779,6 +16905,9 @@ msgstr "Kde jsou seriózní týmy" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Kde relace začínají: domovská stránka, srovnávací stránka, dokumentace, příspěvek nebo záznam v changelogu. Obsah, který se umisťuje, se objeví tady dřív než v registracích." +msgid "Where the container holding this project's checkouts is created." +msgstr "Kde se vytvoří kontejner s pracovními kopiemi tohoto projektu." + msgid "Where the industry is, honestly" msgstr "Kde obor upřímně je" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index d9c9aff7033..323c0dcf914 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· aktualisiert {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "„{0}“ konnte nicht hinzugefügt werden: {1}. Die zuvor hinzugefügten Quellordner bleiben erhalten — entfernen Sie den Ordner oder beheben Sie das Problem und erstellen Sie das Projekt erneut." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\" gelöscht" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} von {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# Quellordner} other {# Quellordner}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {# Automatisierung wird erneut versucht} other {# Automatisierungen werden erneut versucht}}" @@ -1381,6 +1389,7 @@ msgstr "Aktion" msgid "Actions" msgstr "Aktionen" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Aktionen für {0}" @@ -1438,6 +1447,9 @@ msgstr "Füge ein Projekt hinzu, um GitHub-Issues zu sehen." msgid "Add a project to see pull requests." msgstr "Füge ein Projekt hinzu, um Pull Requests zu sehen." +msgid "Add a repository folder" +msgstr "Repository-Ordner hinzufügen" + msgid "Add account" msgstr "Konto hinzufügen" @@ -1492,6 +1504,9 @@ msgstr "Dateien, Apps und mehr hinzufügen" msgid "Add files, apps, and more (@)" msgstr "Dateien, Apps und mehr hinzufügen (@)" +msgid "Add folder" +msgstr "Ordner hinzufügen" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Issue #{0} zum Arbeitsbereich hinzufügen" @@ -1535,6 +1550,12 @@ msgstr "Tests für den riskantesten ungetesteten Code schreiben…" msgid "Add tests where they're missing" msgstr "Tests ergänzen, wo sie fehlen" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Fügen Sie die Repositorys, mit denen dieses Projekt arbeitet, als Quellordner hinzu. Jeder Arbeitsbereich checkt sie alle aus." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Fügen Sie die Repositorys hinzu, mit denen dieses Projekt arbeitet. Das erste ist das primäre." + msgid "Add to Dictionary" msgstr "Zum Wörterbuch hinzufügen" @@ -1547,6 +1568,10 @@ msgstr "Zum Arbeitsbereich hinzufügen" msgid "Add your own terminal agent to this device." msgstr "Füge diesem Gerät einen eigenen Terminal-Agenten hinzu." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} wurde diesem Projekt hinzugefügt" + msgid "Added {added} files to {where}" msgstr "{added} Dateien zu {where} hinzugefügt" @@ -1973,6 +1998,9 @@ msgstr "Anonymer Monteur" msgid "another device" msgstr "ein anderes Gerät" +msgid "Another folder in this project already has that name." +msgstr "Ein anderer Ordner in diesem Projekt heißt bereits so." + msgid "Answered" msgstr "Beantwortet" @@ -2964,6 +2992,9 @@ msgstr "Gedankenkette" msgid "Change avatar" msgstr "Avatar ändern" +msgid "Change folder" +msgstr "Ordner wechseln" + msgid "Change location" msgstr "Speicherort ändern" @@ -2998,6 +3029,9 @@ msgstr "Worktree-Speicherort ändern" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Change-Failure-Rate auf oder unter dem Niveau der Menschen-Ära über zwei Quartale in Folge." +msgid "Changed files" +msgstr "Geänderte Dateien" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Klone das Repository oder importiere einen vorhandenen Ordner auf dem Ho msgid "Clone, push, and create PRs." msgstr "Klonen, pushen und PRs erstellen." +msgid "Cloned into each new workspace" +msgstr "Wird in jeden neuen Arbeitsbereich geklont" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Nach {0} geklont" @@ -4244,6 +4281,10 @@ msgstr "Projekt konnte nicht erstellt werden" msgid "Could not create project. Please try a different name or check the logs." msgstr "Projekt konnte nicht erstellt werden. Versuche einen anderen Namen oder sieh in den Logs nach." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Projekt konnte nicht erstellt werden: {0}" + msgid "Could not delete account" msgstr "Konto konnte nicht gelöscht werden" @@ -4638,6 +4679,12 @@ msgstr "PR erstellen" msgid "Create PR failed: {0}" msgstr "PR-Erstellung fehlgeschlagen: {0}" +msgid "Create project" +msgstr "Projekt erstellen" + +msgid "Create project with multiple folders" +msgstr "Projekt mit mehreren Ordnern erstellen" + msgid "Create pull request" msgstr "Pull Request erstellen" @@ -4757,6 +4804,9 @@ msgstr "Worktree wird erstellt" msgid "Creating..." msgstr "Wird erstellt..." +msgid "Creating…" +msgstr "Wird erstellt…" + msgid "Creative Manager, Valid" msgstr "Kreativmanager, Valid" @@ -4987,6 +5037,9 @@ msgstr "“{title}” löschen?" msgid "Delete {0}?" msgstr "{0} löschen?" +msgid "Delete {groupName}?" +msgstr "{groupName} löschen?" + msgid "Delete {name}?" msgstr "{name} löschen?" @@ -5625,6 +5678,9 @@ msgstr "Jeder Agent läuft in seinem eigenen isolierten Git-Worktree. Keine Merg msgid "Each attachment is at most {0} MB." msgstr "Jeder Anhang darf höchstens {0} MB groß sein." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Jeder Ordner führt seine eigenen Setup-, Teardown- und Ausführungsbefehle in dieser Reihenfolge aus." + msgid "Each in its own new tab" msgstr "Jeder in einem eigenen neuen Tab" @@ -6787,6 +6843,9 @@ msgstr "Ordner" msgid "Folder links" msgstr "Ordner-Links" +msgid "Folder name" +msgstr "Ordnername" + msgid "Folder view" msgstr "Ordneransicht" @@ -7954,6 +8013,9 @@ msgstr "Eintrag" msgid "Its own branch and copy of the files" msgstr "Eigener Branch und eigene Kopie der Dateien" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Seine Quellordner bleiben als eigenständige Projekte auf diesem Gerät." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Seine Arbeitsbereiche und Terminals sind nicht verfügbar, bis er neu startet — nutze das Superset-Menü in der Menüleiste > Host Service > Restart." @@ -8474,6 +8536,9 @@ msgstr "Diff wird geladen…" msgid "Loading files..." msgstr "Dateien werden geladen..." +msgid "Loading folders…" +msgstr "Ordner werden geladen…" + msgid "Loading fonts..." msgstr "Schriften werden geladen..." @@ -8630,6 +8695,9 @@ msgstr "Als Standard festlegen" msgid "Make default — launch new terminals and agents on this account." msgstr "Als Standard festlegen — neue Terminals und Agenten mit diesem Konto starten." +msgid "Make primary" +msgstr "Als primär festlegen" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Stelle sicher, dass das Gerät wach und online ist und Superset darauf läuft." @@ -9083,6 +9151,9 @@ msgstr "Mein Agent" msgid "My Tasks" msgstr "Meine Aufgaben" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Mysteriöser Maschinist" @@ -9310,6 +9381,12 @@ msgstr "neue Arbeitsbereich-Ersteller" msgid "New workspace in {name}" msgstr "Neuer Arbeitsbereich in {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Neue Arbeitsbereiche checken seine Ordner nicht mehr gemeinsam aus. Bestehende Arbeitsbereiche behalten ihre Checkouts, und es wird kein Repository von der Festplatte gelöscht." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Neue Arbeitsbereiche checken dieses Repository nicht mehr aus. Bestehende Arbeitsbereiche behalten es, und auf der Festplatte wird nichts gelöscht." + msgid "New-user activation" msgstr "Aktivierung neuer Nutzer" @@ -11095,6 +11172,9 @@ msgstr "Wähle ein übergeordnetes Verzeichnis" msgid "Pick a project location" msgstr "Wähle einen Projekt-Speicherort" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Wählen Sie auf {hostName} ein Repository aus, das diesem Projekt hinzugefügt werden soll." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Wähle einen Ton oder füge einen eigenen hinzu. Eigenes Audio unterstützt .mp3, .wav und .ogg." @@ -11392,6 +11472,10 @@ msgstr "Der Preis hängt von deinem Team und deinem Anwendungsfall ab. Melde dic msgid "Primary" msgstr "Primär" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Primär" + msgid "Primary Key" msgstr "Primärschlüssel" @@ -12011,16 +12095,24 @@ msgstr "Remote-Arbeitsbereich — Gerät offline" msgid "Remove" msgstr "Entfernen" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "{0} entfernen" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "{0} aus diesem Projekt entfernen?" + msgid "Remove {label}?" msgstr "{label} entfernen?" msgid "Remove attachment" msgstr "Anhang entfernen" +msgid "Remove folder" +msgstr "Ordner entfernen" + msgid "Remove from Queue" msgstr "Aus Warteschlange entfernen" @@ -12112,6 +12204,9 @@ msgstr "Umbenennen fehlgeschlagen" msgid "Rename failed: {0}" msgstr "Umbenennen fehlgeschlagen: {0}" +msgid "Rename folder" +msgstr "Ordner umbenennen" + msgid "Rename group" msgstr "Gruppe umbenennen" @@ -12219,6 +12314,9 @@ msgstr "Repositories" msgid "Repositories accessible through the GitHub App installation." msgstr "Repositories, die über die Installation der GitHub-App erreichbar sind." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repositorys, die in jeden Arbeitsbereich dieses Projekts ausgecheckt werden. Das primäre ist dasjenige, das Werkzeuge für einzelne Repositorys verwenden." + msgid "Repositories refreshed" msgstr "Repositories aktualisiert" @@ -13144,6 +13242,12 @@ msgstr "Wähle ein Projekt, um Pull Requests zu sehen." msgid "Select a pull request to preview it here." msgstr "Wähle einen Pull Request, um ihn hier in der Vorschau zu sehen." +msgid "Select a repository to add" +msgstr "Hinzuzufügendes Repository auswählen" + +msgid "Select a source folder" +msgstr "Quellordner auswählen" + msgid "Select a team" msgstr "Team auswählen" @@ -13993,6 +14097,13 @@ msgstr "Quelle" msgid "Source Available" msgstr "Source Available" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Quellordner von {0}" + +msgid "Source folders" +msgstr "Quellordner" + msgid "Source path (not an import)" msgstr "Quellpfad (kein Import)" @@ -14939,6 +15050,9 @@ msgstr "Dieser Endpunkt konnte nicht aufgelöst werden." msgid "That endpoint is not allowed." msgstr "Dieser Endpunkt ist nicht zulässig." +msgid "That folder is already in the list" +msgstr "Dieser Ordner ist bereits in der Liste" + msgid "That handle is taken." msgstr "Dieses Handle ist bereits vergeben." @@ -15153,6 +15267,9 @@ msgstr "Die gesuchte Seite gibt es nicht." msgid "The people behind Superset." msgstr "Die Menschen hinter Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "Der primäre Ordner kann nicht entfernt werden. Legen Sie zuerst einen anderen Ordner als primär fest." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Der Checkout des Projekts, wie er ist: Dateien und Branch werden geteilt" @@ -15402,6 +15519,9 @@ msgstr "Das dauert länger als sonst. Die Sitzung ist womöglich schon bereit msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Das dauert länger als sonst. Der Arbeitsbereich ist womöglich schon bereit — ein Neuladen kann ihn aufgreifen." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Dies ist das Verzeichnis, in das das Repository innerhalb jedes Arbeitsbereichs ausgecheckt wird." + msgid "This issue link is invalid." msgstr "Dieser Issue-Link ist ungültig." @@ -15463,6 +15583,9 @@ msgstr "Dieser Port ist noch in Benutzung" msgid "This profile is busy right now" msgstr "Dieses Profil ist gerade stark ausgelastet" +msgid "This project has no folders on {hostName} yet." +msgstr "Dieses Projekt hat auf {hostName} noch keine Ordner." + msgid "This project is no longer available on your devices." msgstr "Dieses Projekt ist auf deinen Geräten nicht mehr verfügbar." @@ -16290,6 +16413,9 @@ msgstr "Für neue Agenten verwenden" msgid "Use global default" msgstr "Globalen Standard verwenden" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Verwenden Sie Buchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche, beginnend mit einem Buchstaben oder einer Ziffer." + msgid "Use Superset on mobile" msgstr "Superset auf dem Handy nutzen" @@ -16779,6 +16905,9 @@ msgstr "Wo ernsthafte Teams stehen" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Wo Sitzungen beginnen: die Startseite, eine Vergleichsseite, die Doku, ein Beitrag oder ein Changelog-Eintrag. Inhalte, die ranken, tauchen hier auf, bevor sie in den Registrierungen auftauchen." +msgid "Where the container holding this project's checkouts is created." +msgstr "Wo der Container angelegt wird, der die Checkouts dieses Projekts enthält." + msgid "Where the industry is, honestly" msgstr "Wo die Branche wirklich steht" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index aa370f11a97..7c52c602e50 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· updated {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\" deleted" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} of {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# source folder} other {# source folders}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" @@ -1381,6 +1389,7 @@ msgstr "Action" msgid "Actions" msgstr "Actions" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Actions for {0}" @@ -1438,6 +1447,9 @@ msgstr "Add a project to see GitHub issues." msgid "Add a project to see pull requests." msgstr "Add a project to see pull requests." +msgid "Add a repository folder" +msgstr "Add a repository folder" + msgid "Add account" msgstr "Add account" @@ -1492,6 +1504,9 @@ msgstr "Add files, apps, and more" msgid "Add files, apps, and more (@)" msgstr "Add files, apps, and more (@)" +msgid "Add folder" +msgstr "Add folder" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Add issue #{0} to workspace" @@ -1535,6 +1550,12 @@ msgstr "Add tests for the riskiest untested code…" msgid "Add tests where they're missing" msgstr "Add tests where they're missing" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Add the repositories this project works across as source folders. Every workspace checks out all of them." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Add the repositories this project works across. The first one is the primary." + msgid "Add to Dictionary" msgstr "Add to Dictionary" @@ -1547,6 +1568,10 @@ msgstr "Add to workspace" msgid "Add your own terminal agent to this device." msgstr "Add your own terminal agent to this device." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "Added {0} to this project" + msgid "Added {added} files to {where}" msgstr "Added {added} files to {where}" @@ -1973,6 +1998,9 @@ msgstr "Anonymous Assembler" msgid "another device" msgstr "another device" +msgid "Another folder in this project already has that name." +msgstr "Another folder in this project already has that name." + msgid "Answered" msgstr "Answered" @@ -2964,6 +2992,9 @@ msgstr "Chain of Thought" msgid "Change avatar" msgstr "Change avatar" +msgid "Change folder" +msgstr "Change folder" + msgid "Change location" msgstr "Change location" @@ -2998,6 +3029,9 @@ msgstr "Change worktree location" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Change-failure rate at or below the human-era baseline for two consecutive quarters." +msgid "Changed files" +msgstr "Changed files" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Clone the repository, or import an existing folder on the host." msgid "Clone, push, and create PRs." msgstr "Clone, push, and create PRs." +msgid "Cloned into each new workspace" +msgstr "Cloned into each new workspace" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Cloned to {0}" @@ -4244,6 +4281,10 @@ msgstr "Could not create project" msgid "Could not create project. Please try a different name or check the logs." msgstr "Could not create project. Please try a different name or check the logs." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Could not create the project: {0}" + msgid "Could not delete account" msgstr "Could not delete account" @@ -4638,6 +4679,12 @@ msgstr "Create PR" msgid "Create PR failed: {0}" msgstr "Create PR failed: {0}" +msgid "Create project" +msgstr "Create project" + +msgid "Create project with multiple folders" +msgstr "Create project with multiple folders" + msgid "Create pull request" msgstr "Create pull request" @@ -4757,6 +4804,9 @@ msgstr "Creating worktree" msgid "Creating..." msgstr "Creating..." +msgid "Creating…" +msgstr "Creating…" + msgid "Creative Manager, Valid" msgstr "Creative Manager, Valid" @@ -4987,6 +5037,9 @@ msgstr "Delete “{title}”?" msgid "Delete {0}?" msgstr "Delete {0}?" +msgid "Delete {groupName}?" +msgstr "Delete {groupName}?" + msgid "Delete {name}?" msgstr "Delete {name}?" @@ -5625,6 +5678,9 @@ msgstr "Each agent runs in its own isolated Git worktree. No merge conflicts, no msgid "Each attachment is at most {0} MB." msgstr "Each attachment is at most {0} MB." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Each folder runs its own setup, teardown and run commands, in this order." + msgid "Each in its own new tab" msgstr "Each in its own new tab" @@ -6787,6 +6843,9 @@ msgstr "Folder" msgid "Folder links" msgstr "Folder links" +msgid "Folder name" +msgstr "Folder name" + msgid "Folder view" msgstr "Folder view" @@ -7954,6 +8013,9 @@ msgstr "Item" msgid "Its own branch and copy of the files" msgstr "Its own branch and copy of the files" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Its source folders stay on this device as projects of their own." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." @@ -8474,6 +8536,9 @@ msgstr "Loading diff…" msgid "Loading files..." msgstr "Loading files..." +msgid "Loading folders…" +msgstr "Loading folders…" + msgid "Loading fonts..." msgstr "Loading fonts..." @@ -8630,6 +8695,9 @@ msgstr "Make default" msgid "Make default — launch new terminals and agents on this account." msgstr "Make default — launch new terminals and agents on this account." +msgid "Make primary" +msgstr "Make primary" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Make sure the device is awake, online, and running Superset." @@ -9083,6 +9151,9 @@ msgstr "My Agent" msgid "My Tasks" msgstr "My Tasks" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Mystery Machinist" @@ -9310,6 +9381,12 @@ msgstr "new workspace creators" msgid "New workspace in {name}" msgstr "New workspace in {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." + msgid "New-user activation" msgstr "New-user activation" @@ -11095,6 +11172,9 @@ msgstr "Pick a parent directory" msgid "Pick a project location" msgstr "Pick a project location" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Pick a repository on {hostName} to add to this project." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." @@ -11392,6 +11472,10 @@ msgstr "Pricing depends on your team and use case. Get in touch and we'll put to msgid "Primary" msgstr "Primary" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Primary" + msgid "Primary Key" msgstr "Primary Key" @@ -12011,16 +12095,24 @@ msgstr "Remote workspace — device offline" msgid "Remove" msgstr "Remove" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Remove {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Remove {0} from this project?" + msgid "Remove {label}?" msgstr "Remove {label}?" msgid "Remove attachment" msgstr "Remove attachment" +msgid "Remove folder" +msgstr "Remove folder" + msgid "Remove from Queue" msgstr "Remove from Queue" @@ -12112,6 +12204,9 @@ msgstr "Rename failed" msgid "Rename failed: {0}" msgstr "Rename failed: {0}" +msgid "Rename folder" +msgstr "Rename folder" + msgid "Rename group" msgstr "Rename group" @@ -12219,6 +12314,9 @@ msgstr "Repositories" msgid "Repositories accessible through the GitHub App installation." msgstr "Repositories accessible through the GitHub App installation." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." + msgid "Repositories refreshed" msgstr "Repositories refreshed" @@ -13144,6 +13242,12 @@ msgstr "Select a project to see pull requests." msgid "Select a pull request to preview it here." msgstr "Select a pull request to preview it here." +msgid "Select a repository to add" +msgstr "Select a repository to add" + +msgid "Select a source folder" +msgstr "Select a source folder" + msgid "Select a team" msgstr "Select a team" @@ -13993,6 +14097,13 @@ msgstr "Source" msgid "Source Available" msgstr "Source Available" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Source folder of {0}" + +msgid "Source folders" +msgstr "Source folders" + msgid "Source path (not an import)" msgstr "Source path (not an import)" @@ -14939,6 +15050,9 @@ msgstr "That endpoint could not be resolved." msgid "That endpoint is not allowed." msgstr "That endpoint is not allowed." +msgid "That folder is already in the list" +msgstr "That folder is already in the list" + msgid "That handle is taken." msgstr "That handle is taken." @@ -15153,6 +15267,9 @@ msgstr "The page you're looking for doesn't exist." msgid "The people behind Superset." msgstr "The people behind Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "The primary folder can't be removed. Make another folder primary first." + msgid "The project's checkout as it is: files and branch are shared" msgstr "The project's checkout as it is: files and branch are shared" @@ -15402,6 +15519,9 @@ msgstr "This is taking longer than usual. The session may already be ready — r msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "This is the directory the repository is checked out into inside each workspace." + msgid "This issue link is invalid." msgstr "This issue link is invalid." @@ -15463,6 +15583,9 @@ msgstr "This port is still in use" msgid "This profile is busy right now" msgstr "This profile is busy right now" +msgid "This project has no folders on {hostName} yet." +msgstr "This project has no folders on {hostName} yet." + msgid "This project is no longer available on your devices." msgstr "This project is no longer available on your devices." @@ -16290,6 +16413,9 @@ msgstr "Use for new agents" msgid "Use global default" msgstr "Use global default" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." + msgid "Use Superset on mobile" msgstr "Use Superset on mobile" @@ -16779,6 +16905,9 @@ msgstr "Where serious teams are" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." +msgid "Where the container holding this project's checkouts is created." +msgstr "Where the container holding this project's checkouts is created." + msgid "Where the industry is, honestly" msgstr "Where the industry is, honestly" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 1b7ae5f40f7..22c7e61a3a2 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· actualizada {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "No se pudo añadir «{0}»: {1}. Se conservaron las carpetas de origen añadidas antes — elimínala o corrígela y vuelve a crear el proyecto." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\" eliminada" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} de {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# carpeta de origen} other {# carpetas de origen}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Reintentando # automatización} other {Reintentando # automatizaciones}}" @@ -1381,6 +1389,7 @@ msgstr "Acción" msgid "Actions" msgstr "Acciones" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Acciones para {0}" @@ -1438,6 +1447,9 @@ msgstr "Añade un proyecto para ver los issues de GitHub." msgid "Add a project to see pull requests." msgstr "Añade un proyecto para ver pull requests." +msgid "Add a repository folder" +msgstr "Añadir una carpeta de repositorio" + msgid "Add account" msgstr "Añadir cuenta" @@ -1492,6 +1504,9 @@ msgstr "Añadir archivos, apps y más" msgid "Add files, apps, and more (@)" msgstr "Añadir archivos, apps y más (@)" +msgid "Add folder" +msgstr "Añadir carpeta" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Añadir el issue #{0} al espacio de trabajo" @@ -1535,6 +1550,12 @@ msgstr "Añadir pruebas para el código sin cobertura más arriesgado…" msgid "Add tests where they're missing" msgstr "Añadir pruebas donde faltan" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Añade como carpetas de origen los repositorios con los que trabaja este proyecto. Cada espacio de trabajo los extrae todos." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Añade los repositorios con los que trabaja este proyecto. El primero es el principal." + msgid "Add to Dictionary" msgstr "Añadir al diccionario" @@ -1547,6 +1568,10 @@ msgstr "Añadir al espacio de trabajo" msgid "Add your own terminal agent to this device." msgstr "Añade tu propio agente de terminal a este dispositivo." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "Se añadió {0} a este proyecto" + msgid "Added {added} files to {where}" msgstr "Se añadieron {added} archivos a {where}" @@ -1973,6 +1998,9 @@ msgstr "Ensamblador anónimo" msgid "another device" msgstr "otro dispositivo" +msgid "Another folder in this project already has that name." +msgstr "Otra carpeta de este proyecto ya tiene ese nombre." + msgid "Answered" msgstr "Respondido" @@ -2964,6 +2992,9 @@ msgstr "Cadena de razonamiento" msgid "Change avatar" msgstr "Cambiar el avatar" +msgid "Change folder" +msgstr "Cambiar carpeta" + msgid "Change location" msgstr "Cambiar ubicación" @@ -2998,6 +3029,9 @@ msgstr "Cambiar la ubicación del worktree" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Tasa de fallos por cambio igual o inferior a la de referencia de la era humana durante dos trimestres seguidos." +msgid "Changed files" +msgstr "Archivos modificados" + msgid "Changelog" msgstr "Registro de cambios" @@ -3334,6 +3368,9 @@ msgstr "Clona el repositorio o importa una carpeta existente del host." msgid "Clone, push, and create PRs." msgstr "Clona, envía cambios y crea PR." +msgid "Cloned into each new workspace" +msgstr "Se clona en cada nuevo espacio de trabajo" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Clonado en {0}" @@ -4244,6 +4281,10 @@ msgstr "No se pudo crear el proyecto" msgid "Could not create project. Please try a different name or check the logs." msgstr "No se pudo crear el proyecto. Prueba con otro nombre o revisa los logs." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "No se pudo crear el proyecto: {0}" + msgid "Could not delete account" msgstr "No se pudo eliminar la cuenta" @@ -4638,6 +4679,12 @@ msgstr "Crear PR" msgid "Create PR failed: {0}" msgstr "Error al crear el PR: {0}" +msgid "Create project" +msgstr "Crear proyecto" + +msgid "Create project with multiple folders" +msgstr "Crear proyecto con varias carpetas" + msgid "Create pull request" msgstr "Crear pull request" @@ -4757,6 +4804,9 @@ msgstr "Creando el worktree" msgid "Creating..." msgstr "Creando..." +msgid "Creating…" +msgstr "Creando…" + msgid "Creative Manager, Valid" msgstr "Director creativo, Valid" @@ -4987,6 +5037,9 @@ msgstr "¿Eliminar “{title}”?" msgid "Delete {0}?" msgstr "¿Eliminar {0}?" +msgid "Delete {groupName}?" +msgstr "¿Eliminar {groupName}?" + msgid "Delete {name}?" msgstr "¿Eliminar {name}?" @@ -5625,6 +5678,9 @@ msgstr "Cada agente se ejecuta en su propio worktree de Git aislado. Sin conflic msgid "Each attachment is at most {0} MB." msgstr "Cada archivo adjunto es de {0} MB como máximo." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Cada carpeta ejecuta sus propios comandos de preparación, desmontaje y ejecución, en este orden." + msgid "Each in its own new tab" msgstr "Cada uno en su propia pestaña" @@ -6787,6 +6843,9 @@ msgstr "Carpeta" msgid "Folder links" msgstr "Enlaces a carpetas" +msgid "Folder name" +msgstr "Nombre de la carpeta" + msgid "Folder view" msgstr "Vista de carpetas" @@ -7954,6 +8013,9 @@ msgstr "Elemento" msgid "Its own branch and copy of the files" msgstr "Su propia rama y su propia copia de los archivos" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Sus carpetas de origen permanecen en este dispositivo como proyectos independientes." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Sus espacios de trabajo y terminales no estarán disponibles hasta que se reinicie: usa el menú de la bandeja de Superset > Host Service > Reiniciar." @@ -8474,6 +8536,9 @@ msgstr "Cargando diff…" msgid "Loading files..." msgstr "Cargando los archivos..." +msgid "Loading folders…" +msgstr "Cargando carpetas…" + msgid "Loading fonts..." msgstr "Cargando fuentes..." @@ -8630,6 +8695,9 @@ msgstr "Usar como predeterminada" msgid "Make default — launch new terminals and agents on this account." msgstr "Usar como predeterminada: las terminales y los agentes nuevos se lanzan con esta cuenta." +msgid "Make primary" +msgstr "Marcar como principal" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Asegúrate de que el dispositivo esté activo, conectado y con Superset en ejecución." @@ -9083,6 +9151,9 @@ msgstr "Mi agente" msgid "My Tasks" msgstr "Mis tareas" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Maquinista misterioso" @@ -9310,6 +9381,12 @@ msgstr "nuevos creadores de espacios de trabajo" msgid "New workspace in {name}" msgstr "Nuevo espacio de trabajo en {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Los nuevos espacios de trabajo dejarán de extraer sus carpetas juntas. Los espacios de trabajo existentes conservan sus extracciones y no se elimina ningún repositorio del disco." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Los nuevos espacios de trabajo dejarán de extraer este repositorio. Los espacios de trabajo existentes lo conservan y no se elimina nada del disco." + msgid "New-user activation" msgstr "Activación de usuarios nuevos" @@ -11095,6 +11172,9 @@ msgstr "Elige un directorio padre" msgid "Pick a project location" msgstr "Elige una ubicación para el proyecto" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Elige un repositorio en {hostName} para añadirlo a este proyecto." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Elige un sonido o añade el tuyo. El audio propio admite .mp3, .wav y .ogg." @@ -11392,6 +11472,10 @@ msgstr "El precio depende de tu equipo y de tu caso de uso. Escríbenos y prepar msgid "Primary" msgstr "Primario" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Principal" + msgid "Primary Key" msgstr "Clave primaria" @@ -12011,16 +12095,24 @@ msgstr "Espacio de trabajo remoto: dispositivo sin conexión" msgid "Remove" msgstr "Quitar" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Quitar a {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "¿Quitar {0} de este proyecto?" + msgid "Remove {label}?" msgstr "¿Quitar {label}?" msgid "Remove attachment" msgstr "Quitar adjunto" +msgid "Remove folder" +msgstr "Quitar carpeta" + msgid "Remove from Queue" msgstr "Quitar de la cola" @@ -12112,6 +12204,9 @@ msgstr "No se pudo cambiar el nombre" msgid "Rename failed: {0}" msgstr "No se pudo cambiar el nombre: {0}" +msgid "Rename folder" +msgstr "Renombrar carpeta" + msgid "Rename group" msgstr "Cambiar nombre del grupo" @@ -12219,6 +12314,9 @@ msgstr "Repositorios" msgid "Repositories accessible through the GitHub App installation." msgstr "Repositorios accesibles a través de la instalación de la GitHub App." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repositorios que se extraen en cada espacio de trabajo de este proyecto. El principal es el que usan las herramientas de un solo repositorio." + msgid "Repositories refreshed" msgstr "Repositorios actualizados" @@ -13144,6 +13242,12 @@ msgstr "Selecciona un proyecto para ver pull requests." msgid "Select a pull request to preview it here." msgstr "Selecciona una pull request para verla aquí." +msgid "Select a repository to add" +msgstr "Seleccionar un repositorio para añadir" + +msgid "Select a source folder" +msgstr "Seleccionar una carpeta de origen" + msgid "Select a team" msgstr "Selecciona un equipo" @@ -13993,6 +14097,13 @@ msgstr "Origen" msgid "Source Available" msgstr "Código disponible" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Carpeta de origen de {0}" + +msgid "Source folders" +msgstr "Carpetas de origen" + msgid "Source path (not an import)" msgstr "Ruta del código fuente (no es una importación)" @@ -14939,6 +15050,9 @@ msgstr "No se pudo resolver ese endpoint." msgid "That endpoint is not allowed." msgstr "Ese endpoint no está permitido." +msgid "That folder is already in the list" +msgstr "Esa carpeta ya está en la lista" + msgid "That handle is taken." msgstr "Ese nombre de usuario ya está en uso." @@ -15153,6 +15267,9 @@ msgstr "La página solicitada no existe." msgid "The people behind Superset." msgstr "Las personas detrás de Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "La carpeta principal no se puede quitar. Marca antes otra carpeta como principal." + msgid "The project's checkout as it is: files and branch are shared" msgstr "El checkout del proyecto tal cual: los archivos y la rama se comparten" @@ -15402,6 +15519,9 @@ msgstr "Está tardando más de lo normal. Puede que la sesión ya esté lista: a msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Está tardando más de lo normal. Puede que el espacio de trabajo ya esté listo: al recargar puede aparecer." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Es el directorio en el que se extrae el repositorio dentro de cada espacio de trabajo." + msgid "This issue link is invalid." msgstr "Este enlace de issue no es válido." @@ -15463,6 +15583,9 @@ msgstr "Este puerto sigue en uso" msgid "This profile is busy right now" msgstr "Este perfil está saturado ahora mismo" +msgid "This project has no folders on {hostName} yet." +msgstr "Este proyecto aún no tiene carpetas en {hostName}." + msgid "This project is no longer available on your devices." msgstr "Este proyecto ya no está disponible en tus dispositivos." @@ -16290,6 +16413,9 @@ msgstr "Usar para los agentes nuevos" msgid "Use global default" msgstr "Usar el valor global predeterminado" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Usa letras, dígitos, puntos, guiones o guiones bajos, empezando por una letra o un dígito." + msgid "Use Superset on mobile" msgstr "Usa Superset en el móvil" @@ -16779,6 +16905,9 @@ msgstr "Donde están los equipos serios" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Dónde empiezan las sesiones: la portada, una página de comparación, la documentación, un artículo o una entrada del changelog. El contenido que posiciona aparece aquí antes que en los registros." +msgid "Where the container holding this project's checkouts is created." +msgstr "Dónde se crea el contenedor que agrupa las extracciones de este proyecto." + msgid "Where the industry is, honestly" msgstr "Dónde está el sector, con honestidad" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index d86b3ee52c2..4b096a8943b 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· mise à jour {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "« {0} » n'a pas pu être ajouté : {1}. Les dossiers sources ajoutés avant ont été conservés — retirez-le ou corrigez-le, puis relancez la création." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "« {0} » supprimée" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} sur {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# dossier source} other {# dossiers sources}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Nouvelle tentative pour # automatisation} other {Nouvelle tentative pour # automatisations}}" @@ -1381,6 +1389,7 @@ msgstr "Action" msgid "Actions" msgstr "Actions" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Actions pour {0}" @@ -1438,6 +1447,9 @@ msgstr "Ajoutez un projet pour voir les issues GitHub." msgid "Add a project to see pull requests." msgstr "Ajoutez un projet pour voir les pull requests." +msgid "Add a repository folder" +msgstr "Ajouter un dossier de dépôt" + msgid "Add account" msgstr "Ajouter un compte" @@ -1492,6 +1504,9 @@ msgstr "Ajouter des fichiers, des applis, et plus" msgid "Add files, apps, and more (@)" msgstr "Ajouter des fichiers, des applis, et plus (@)" +msgid "Add folder" +msgstr "Ajouter un dossier" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Ajouter l'issue #{0} à un espace de travail" @@ -1535,6 +1550,12 @@ msgstr "Ajoute des tests pour le code non testé le plus risqué…" msgid "Add tests where they're missing" msgstr "Ajouter des tests là où il en manque" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Ajoutez les dépôts sur lesquels ce projet travaille comme dossiers sources. Chaque espace de travail les extrait tous." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Ajoutez les dépôts sur lesquels ce projet travaille. Le premier est le principal." + msgid "Add to Dictionary" msgstr "Ajouter au dictionnaire" @@ -1547,6 +1568,10 @@ msgstr "Ajouter à un espace de travail" msgid "Add your own terminal agent to this device." msgstr "Ajoutez votre propre agent de terminal sur cet appareil." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} a été ajouté à ce projet" + msgid "Added {added} files to {where}" msgstr "{added} fichiers ajoutés à {where}" @@ -1973,6 +1998,9 @@ msgstr "Assembleur anonyme" msgid "another device" msgstr "un autre appareil" +msgid "Another folder in this project already has that name." +msgstr "Un autre dossier de ce projet porte déjà ce nom." + msgid "Answered" msgstr "Répondu" @@ -2964,6 +2992,9 @@ msgstr "Chaîne de raisonnement" msgid "Change avatar" msgstr "Changer d'avatar" +msgid "Change folder" +msgstr "Changer de dossier" + msgid "Change location" msgstr "Changer l'emplacement" @@ -2998,6 +3029,9 @@ msgstr "Changer l'emplacement des worktrees" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Taux d'échec des changements égal ou inférieur au niveau de référence de l'ère humaine pendant deux trimestres consécutifs." +msgid "Changed files" +msgstr "Fichiers modifiés" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Clonez le dépôt, ou importez un dossier existant sur l'hôte." msgid "Clone, push, and create PRs." msgstr "Clonez, poussez et créez des PR." +msgid "Cloned into each new workspace" +msgstr "Cloné dans chaque nouvel espace de travail" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Cloné dans {0}" @@ -4244,6 +4281,10 @@ msgstr "Impossible de créer le projet" msgid "Could not create project. Please try a different name or check the logs." msgstr "Impossible de créer le projet. Essayez un autre nom ou consultez les logs." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Impossible de créer le projet : {0}" + msgid "Could not delete account" msgstr "Impossible de supprimer le compte" @@ -4638,6 +4679,12 @@ msgstr "Créer une PR" msgid "Create PR failed: {0}" msgstr "Échec de la création de la PR : {0}" +msgid "Create project" +msgstr "Créer le projet" + +msgid "Create project with multiple folders" +msgstr "Créer un projet avec plusieurs dossiers" + msgid "Create pull request" msgstr "Créer la pull request" @@ -4757,6 +4804,9 @@ msgstr "Création du worktree" msgid "Creating..." msgstr "Création..." +msgid "Creating…" +msgstr "Création…" + msgid "Creative Manager, Valid" msgstr "Responsable créatif, Valid" @@ -4987,6 +5037,9 @@ msgstr "Supprimer « {title} » ?" msgid "Delete {0}?" msgstr "Supprimer {0} ?" +msgid "Delete {groupName}?" +msgstr "Supprimer {groupName} ?" + msgid "Delete {name}?" msgstr "Supprimer {name} ?" @@ -5625,6 +5678,9 @@ msgstr "Chaque agent s'exécute dans son propre worktree Git isolé. Aucun confl msgid "Each attachment is at most {0} MB." msgstr "Chaque pièce jointe fait au maximum {0} Mo." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Chaque dossier exécute ses propres commandes de configuration, de nettoyage et d'exécution, dans cet ordre." + msgid "Each in its own new tab" msgstr "Chacune dans son propre nouvel onglet" @@ -6787,6 +6843,9 @@ msgstr "Dossier" msgid "Folder links" msgstr "Liens vers des dossiers" +msgid "Folder name" +msgstr "Nom du dossier" + msgid "Folder view" msgstr "Vue par dossiers" @@ -7954,6 +8013,9 @@ msgstr "Élément" msgid "Its own branch and copy of the files" msgstr "Sa propre branche et sa propre copie des fichiers" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Ses dossiers sources restent sur cet appareil en tant que projets à part entière." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Ses espaces de travail et terminaux sont indisponibles jusqu'à son redémarrage — utilisez le menu de la barre d'état Superset > Service hôte > Redémarrer." @@ -8474,6 +8536,9 @@ msgstr "Chargement du diff…" msgid "Loading files..." msgstr "Chargement des fichiers..." +msgid "Loading folders…" +msgstr "Chargement des dossiers…" + msgid "Loading fonts..." msgstr "Chargement des polices..." @@ -8630,6 +8695,9 @@ msgstr "Définir par défaut" msgid "Make default — launch new terminals and agents on this account." msgstr "Définir par défaut — lancer les nouveaux terminaux et agents sur ce compte." +msgid "Make primary" +msgstr "Définir comme principal" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Assurez-vous que l’appareil est actif, en ligne et que Superset y est lancé." @@ -9083,6 +9151,9 @@ msgstr "Mon agent" msgid "My Tasks" msgstr "Mes tâches" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Machiniste mystère" @@ -9310,6 +9381,12 @@ msgstr "nouveaux créateurs d'espaces de travail" msgid "New workspace in {name}" msgstr "Nouvel espace de travail dans {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Les nouveaux espaces de travail n'extrairont plus ses dossiers ensemble. Les espaces de travail existants conservent leurs extractions et aucun dépôt n'est supprimé du disque." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Les nouveaux espaces de travail cesseront d'extraire ce dépôt. Les espaces de travail existants le conservent et rien n'est supprimé du disque." + msgid "New-user activation" msgstr "Activation des nouveaux utilisateurs" @@ -11095,6 +11172,9 @@ msgstr "Choisissez un répertoire parent" msgid "Pick a project location" msgstr "Choisissez un emplacement de projet" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Choisissez un dépôt sur {hostName} à ajouter à ce projet." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Choisissez un son ou ajoutez le vôtre. Les sons personnalisés acceptent .mp3, .wav et .ogg." @@ -11392,6 +11472,10 @@ msgstr "Le tarif dépend de votre équipe et de votre cas d'usage. Contactez-nou msgid "Primary" msgstr "Principale" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Principal" + msgid "Primary Key" msgstr "Clé primaire" @@ -12011,16 +12095,24 @@ msgstr "Espace de travail distant — appareil hors ligne" msgid "Remove" msgstr "Retirer" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Retirer {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Retirer {0} de ce projet ?" + msgid "Remove {label}?" msgstr "Retirer {label} ?" msgid "Remove attachment" msgstr "Retirer la pièce jointe" +msgid "Remove folder" +msgstr "Retirer le dossier" + msgid "Remove from Queue" msgstr "Retirer de la file" @@ -12112,6 +12204,9 @@ msgstr "Échec du renommage" msgid "Rename failed: {0}" msgstr "Échec du renommage : {0}" +msgid "Rename folder" +msgstr "Renommer le dossier" + msgid "Rename group" msgstr "Renommer le groupe" @@ -12219,6 +12314,9 @@ msgstr "Dépôts" msgid "Repositories accessible through the GitHub App installation." msgstr "Dépôts accessibles via l'installation de la GitHub App." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Dépôts extraits dans chaque espace de travail de ce projet. Le principal est celui qu'utilisent les outils mono-dépôt." + msgid "Repositories refreshed" msgstr "Dépôts actualisés" @@ -13144,6 +13242,12 @@ msgstr "Sélectionnez un projet pour voir les pull requests." msgid "Select a pull request to preview it here." msgstr "Sélectionnez une pull request pour la prévisualiser ici." +msgid "Select a repository to add" +msgstr "Sélectionner un dépôt à ajouter" + +msgid "Select a source folder" +msgstr "Sélectionner un dossier source" + msgid "Select a team" msgstr "Sélectionner une équipe" @@ -13993,6 +14097,13 @@ msgstr "Source" msgid "Source Available" msgstr "Code source consultable" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Dossier source de {0}" + +msgid "Source folders" +msgstr "Dossiers sources" + msgid "Source path (not an import)" msgstr "Chemin source (pas un import)" @@ -14939,6 +15050,9 @@ msgstr "Ce point de terminaison n'a pas pu être résolu." msgid "That endpoint is not allowed." msgstr "Ce point de terminaison n'est pas autorisé." +msgid "That folder is already in the list" +msgstr "Ce dossier est déjà dans la liste" + msgid "That handle is taken." msgstr "Ce pseudo est déjà pris." @@ -15153,6 +15267,9 @@ msgstr "La page que vous recherchez n'existe pas." msgid "The people behind Superset." msgstr "Les personnes derrière Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "Le dossier principal ne peut pas être retiré. Définissez d'abord un autre dossier comme principal." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Le checkout du projet tel quel: les fichiers et la branche sont partagés" @@ -15402,6 +15519,9 @@ msgstr "Cela prend plus de temps que d'habitude. La session est peut-être déj msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Cela prend plus de temps que d'habitude. L'espace de travail est peut-être déjà prêt — recharger peut le récupérer." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Il s'agit du répertoire dans lequel le dépôt est extrait au sein de chaque espace de travail." + msgid "This issue link is invalid." msgstr "Ce lien d'issue est invalide." @@ -15463,6 +15583,9 @@ msgstr "Ce port est toujours utilisé" msgid "This profile is busy right now" msgstr "Ce profil est très sollicité en ce moment" +msgid "This project has no folders on {hostName} yet." +msgstr "Ce projet n'a encore aucun dossier sur {hostName}." + msgid "This project is no longer available on your devices." msgstr "Ce projet n'est plus disponible sur vos appareils." @@ -16290,6 +16413,9 @@ msgstr "Utiliser pour les nouveaux agents" msgid "Use global default" msgstr "Utiliser la valeur globale par défaut" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Utilisez des lettres, chiffres, points, tirets ou traits de soulignement, en commençant par une lettre ou un chiffre." + msgid "Use Superset on mobile" msgstr "Utiliser Superset sur mobile" @@ -16779,6 +16905,9 @@ msgstr "Là où en sont les équipes sérieuses" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Où commencent les sessions : la page d'accueil, une page de comparaison, la documentation, un article ou une entrée du changelog. Le contenu qui se positionne apparaît ici avant d'apparaître dans les inscriptions." +msgid "Where the container holding this project's checkouts is created." +msgstr "Emplacement où est créé le conteneur qui regroupe les extractions de ce projet." + msgid "Where the industry is, honestly" msgstr "Où en est le secteur, honnêtement" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 4a4982d9006..25335782d05 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· diperbarui {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "“{0}” tidak dapat ditambahkan: {1}. Folder sumber yang ditambahkan sebelumnya tetap ada — hapus folder itu atau perbaiki, lalu buat lagi." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\" dihapus" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} dari {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# folder sumber} other {# folder sumber}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Mencoba ulang # otomatisasi} other {Mencoba ulang # otomatisasi}}" @@ -1381,6 +1389,7 @@ msgstr "Aksi" msgid "Actions" msgstr "Aksi" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Aksi untuk {0}" @@ -1438,6 +1447,9 @@ msgstr "Tambahkan proyek untuk melihat issue GitHub." msgid "Add a project to see pull requests." msgstr "Tambahkan proyek untuk melihat pull request." +msgid "Add a repository folder" +msgstr "Tambahkan folder repositori" + msgid "Add account" msgstr "Tambah akun" @@ -1492,6 +1504,9 @@ msgstr "Tambah file, aplikasi, dan lainnya" msgid "Add files, apps, and more (@)" msgstr "Tambah file, aplikasi, dan lainnya (@)" +msgid "Add folder" +msgstr "Tambah folder" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Tambahkan issue #{0} ke workspace" @@ -1535,6 +1550,12 @@ msgstr "Tambahkan tes untuk kode paling berisiko yang belum diuji…" msgid "Add tests where they're missing" msgstr "Tambahkan tes yang belum ada" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Tambahkan repositori yang dipakai proyek ini sebagai folder sumber. Setiap workspace melakukan checkout semuanya." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Tambahkan repositori yang dipakai proyek ini. Yang pertama menjadi yang utama." + msgid "Add to Dictionary" msgstr "Tambahkan ke Kamus" @@ -1547,6 +1568,10 @@ msgstr "Tambahkan ke workspace" msgid "Add your own terminal agent to this device." msgstr "Tambahkan agen terminal Anda sendiri ke perangkat ini." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} ditambahkan ke proyek ini" + msgid "Added {added} files to {where}" msgstr "Menambahkan {added} file ke {where}" @@ -1973,6 +1998,9 @@ msgstr "Perakit Anonim" msgid "another device" msgstr "perangkat lain" +msgid "Another folder in this project already has that name." +msgstr "Folder lain di proyek ini sudah memakai nama itu." + msgid "Answered" msgstr "Dijawab" @@ -2964,6 +2992,9 @@ msgstr "Rantai Pemikiran" msgid "Change avatar" msgstr "Ganti avatar" +msgid "Change folder" +msgstr "Ubah folder" + msgid "Change location" msgstr "Ubah lokasi" @@ -2998,6 +3029,9 @@ msgstr "Ubah lokasi worktree" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Tingkat kegagalan perubahan sama atau di bawah baseline era manusia selama dua kuartal berturut-turut." +msgid "Changed files" +msgstr "Berkas yang diubah" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Clone repositorinya, atau impor folder yang sudah ada di host." msgid "Clone, push, and create PRs." msgstr "Klon, push, dan buat PR." +msgid "Cloned into each new workspace" +msgstr "Diklon ke setiap workspace baru" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Di-clone ke {0}" @@ -4244,6 +4281,10 @@ msgstr "Tidak bisa membuat proyek" msgid "Could not create project. Please try a different name or check the logs." msgstr "Tidak bisa membuat proyek. Coba nama lain atau periksa log." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Tidak dapat membuat proyek: {0}" + msgid "Could not delete account" msgstr "Tidak bisa menghapus akun" @@ -4638,6 +4679,12 @@ msgstr "Buat PR" msgid "Create PR failed: {0}" msgstr "Gagal membuat PR: {0}" +msgid "Create project" +msgstr "Buat proyek" + +msgid "Create project with multiple folders" +msgstr "Buat proyek dengan beberapa folder" + msgid "Create pull request" msgstr "Buat pull request" @@ -4757,6 +4804,9 @@ msgstr "Membuat worktree" msgid "Creating..." msgstr "Membuat..." +msgid "Creating…" +msgstr "Membuat…" + msgid "Creative Manager, Valid" msgstr "Manajer Kreatif, Valid" @@ -4987,6 +5037,9 @@ msgstr "Hapus “{title}”?" msgid "Delete {0}?" msgstr "Hapus {0}?" +msgid "Delete {groupName}?" +msgstr "Hapus {groupName}?" + msgid "Delete {name}?" msgstr "Hapus {name}?" @@ -5625,6 +5678,9 @@ msgstr "Tiap agen berjalan di worktree Git terisolasi sendiri. Tanpa konflik mer msgid "Each attachment is at most {0} MB." msgstr "Setiap lampiran maksimal {0} MB." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Setiap folder menjalankan perintah setup, teardown, dan run miliknya sendiri, dalam urutan ini." + msgid "Each in its own new tab" msgstr "Masing-masing di tab barunya sendiri" @@ -6787,6 +6843,9 @@ msgstr "Folder" msgid "Folder links" msgstr "Tautan folder" +msgid "Folder name" +msgstr "Nama folder" + msgid "Folder view" msgstr "Tampilan folder" @@ -7954,6 +8013,9 @@ msgstr "Item" msgid "Its own branch and copy of the files" msgstr "Branch dan salinan file tersendiri" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Folder sumbernya tetap ada di perangkat ini sebagai proyek tersendiri." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Workspace dan terminalnya tidak tersedia sampai layanan ini dimulai ulang — pakai menu tray Superset > Host Service > Restart." @@ -8474,6 +8536,9 @@ msgstr "Memuat diff…" msgid "Loading files..." msgstr "Memuat file..." +msgid "Loading folders…" +msgstr "Memuat folder…" + msgid "Loading fonts..." msgstr "Memuat font..." @@ -8630,6 +8695,9 @@ msgstr "Jadikan bawaan" msgid "Make default — launch new terminals and agents on this account." msgstr "Jadikan bawaan — jalankan terminal dan agen baru dengan akun ini." +msgid "Make primary" +msgstr "Jadikan utama" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Pastikan perangkat aktif, terhubung ke internet, dan menjalankan Superset." @@ -9083,6 +9151,9 @@ msgstr "Agen Saya" msgid "My Tasks" msgstr "Tugas Saya" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Masinis Misterius" @@ -9310,6 +9381,12 @@ msgstr "pembuat workspace baru" msgid "New workspace in {name}" msgstr "Workspace baru di {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Workspace baru tidak akan lagi melakukan checkout folder-foldernya bersamaan. Workspace yang sudah ada tetap menyimpan checkout-nya, dan tidak ada repositori yang dihapus dari disk." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Workspace baru tidak akan lagi melakukan checkout repositori ini. Workspace yang sudah ada tetap menyimpannya, dan tidak ada yang dihapus dari disk." + msgid "New-user activation" msgstr "Aktivasi pengguna baru" @@ -11095,6 +11172,9 @@ msgstr "Pilih direktori induk" msgid "Pick a project location" msgstr "Pilih lokasi proyek" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Pilih repositori di {hostName} untuk ditambahkan ke proyek ini." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Pilih suara atau tambahkan milik Anda sendiri. Audio kustom mendukung .mp3, .wav, dan .ogg." @@ -11392,6 +11472,10 @@ msgstr "Harga tergantung tim dan kasus penggunaan Anda. Hubungi kami dan kami ak msgid "Primary" msgstr "Primer" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Utama" + msgid "Primary Key" msgstr "Primary Key" @@ -12011,16 +12095,24 @@ msgstr "Workspace jarak jauh — perangkat offline" msgid "Remove" msgstr "Hapus" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Hapus {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Hapus {0} dari proyek ini?" + msgid "Remove {label}?" msgstr "Hapus {label}?" msgid "Remove attachment" msgstr "Hapus lampiran" +msgid "Remove folder" +msgstr "Hapus folder" + msgid "Remove from Queue" msgstr "Keluarkan dari Antrean" @@ -12112,6 +12204,9 @@ msgstr "Ganti nama gagal" msgid "Rename failed: {0}" msgstr "Ganti nama gagal: {0}" +msgid "Rename folder" +msgstr "Ganti nama folder" + msgid "Rename group" msgstr "Ganti nama grup" @@ -12219,6 +12314,9 @@ msgstr "Repositori" msgid "Repositories accessible through the GitHub App installation." msgstr "Repositori yang bisa diakses lewat instalasi GitHub App." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repositori yang di-checkout ke setiap workspace proyek ini. Yang utama adalah yang dipakai alat repositori tunggal." + msgid "Repositories refreshed" msgstr "Repositori disegarkan" @@ -13144,6 +13242,12 @@ msgstr "Pilih proyek untuk melihat pull request." msgid "Select a pull request to preview it here." msgstr "Pilih pull request untuk melihat pratinjaunya di sini." +msgid "Select a repository to add" +msgstr "Pilih repositori untuk ditambahkan" + +msgid "Select a source folder" +msgstr "Pilih folder sumber" + msgid "Select a team" msgstr "Pilih sebuah tim" @@ -13993,6 +14097,13 @@ msgstr "Sumber" msgid "Source Available" msgstr "Source Available" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Folder sumber dari {0}" + +msgid "Source folders" +msgstr "Folder sumber" + msgid "Source path (not an import)" msgstr "Path sumber (bukan impor)" @@ -14939,6 +15050,9 @@ msgstr "Endpoint itu tidak dapat ditemukan." msgid "That endpoint is not allowed." msgstr "Endpoint itu tidak diizinkan." +msgid "That folder is already in the list" +msgstr "Folder itu sudah ada di daftar" + msgid "That handle is taken." msgstr "Handle itu sudah dipakai." @@ -15153,6 +15267,9 @@ msgstr "Halaman yang Anda cari tidak ada." msgid "The people behind Superset." msgstr "Orang-orang di balik Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "Folder utama tidak dapat dihapus. Jadikan folder lain sebagai utama terlebih dahulu." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Checkout proyek apa adanya: file dan branch dibagikan" @@ -15402,6 +15519,9 @@ msgstr "Ini lebih lama dari biasanya. Sesinya mungkin sudah siap — memuat ulan msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Ini lebih lama dari biasanya. Workspace-nya mungkin sudah siap — memuat ulang bisa menampilkannya." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Ini adalah direktori tempat repositori di-checkout di dalam setiap workspace." + msgid "This issue link is invalid." msgstr "Tautan issue ini tidak valid." @@ -15463,6 +15583,9 @@ msgstr "Port ini masih dipakai" msgid "This profile is busy right now" msgstr "Profil ini sedang sibuk" +msgid "This project has no folders on {hostName} yet." +msgstr "Proyek ini belum memiliki folder di {hostName}." + msgid "This project is no longer available on your devices." msgstr "Proyek ini sudah tidak tersedia di perangkat Anda." @@ -16290,6 +16413,9 @@ msgstr "Pakai untuk agen baru" msgid "Use global default" msgstr "Pakai bawaan global" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Gunakan huruf, angka, titik, tanda hubung, atau garis bawah, diawali huruf atau angka." + msgid "Use Superset on mobile" msgstr "Gunakan Superset di ponsel" @@ -16779,6 +16905,9 @@ msgstr "Tempat tim serius berada" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Tempat sesi dimulai: beranda, halaman perbandingan, dokumentasi, artikel, atau entri changelog. Konten yang berperingkat muncul di sini sebelum muncul di pendaftaran." +msgid "Where the container holding this project's checkouts is created." +msgstr "Tempat kontainer yang menampung checkout proyek ini dibuat." + msgid "Where the industry is, honestly" msgstr "Posisi industri, sejujurnya" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 2a08878602f..19a2ee10710 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· aggiornata {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "Non è stato possibile aggiungere “{0}”: {1}. Le cartelle sorgente aggiunte prima sono state mantenute — rimuovila o correggila e riprova a creare il progetto." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\" eliminata" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} di {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# cartella sorgente} other {# cartelle sorgente}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Nuovo tentativo per # automazione} other {Nuovo tentativo per # automazioni}}" @@ -1381,6 +1389,7 @@ msgstr "Azione" msgid "Actions" msgstr "Azioni" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Azioni per {0}" @@ -1438,6 +1447,9 @@ msgstr "Aggiungi un progetto per vedere le issue GitHub." msgid "Add a project to see pull requests." msgstr "Aggiungi un progetto per vedere le pull request." +msgid "Add a repository folder" +msgstr "Aggiungi una cartella di repository" + msgid "Add account" msgstr "Aggiungi account" @@ -1492,6 +1504,9 @@ msgstr "Aggiungi file, app e altro" msgid "Add files, apps, and more (@)" msgstr "Aggiungi file, app e altro (@)" +msgid "Add folder" +msgstr "Aggiungi cartella" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Aggiungi la issue #{0} al workspace" @@ -1535,6 +1550,12 @@ msgstr "Aggiungi test per il codice non testato più a rischio…" msgid "Add tests where they're missing" msgstr "Aggiungi i test che mancano" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Aggiungi come cartelle sorgente i repository su cui lavora questo progetto. Ogni workspace ne effettua il checkout di tutti." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Aggiungi i repository su cui lavora questo progetto. Il primo è quello principale." + msgid "Add to Dictionary" msgstr "Aggiungi al dizionario" @@ -1547,6 +1568,10 @@ msgstr "Aggiungi al workspace" msgid "Add your own terminal agent to this device." msgstr "Aggiungi un tuo agente da terminale a questo dispositivo." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} è stato aggiunto a questo progetto" + msgid "Added {added} files to {where}" msgstr "Aggiunti {added} file a {where}" @@ -1973,6 +1998,9 @@ msgstr "Assemblatore Anonimo" msgid "another device" msgstr "un altro dispositivo" +msgid "Another folder in this project already has that name." +msgstr "Un'altra cartella di questo progetto ha già questo nome." + msgid "Answered" msgstr "Risposto" @@ -2964,6 +2992,9 @@ msgstr "Catena di pensiero" msgid "Change avatar" msgstr "Cambia avatar" +msgid "Change folder" +msgstr "Cambia cartella" + msgid "Change location" msgstr "Cambia posizione" @@ -2998,6 +3029,9 @@ msgstr "Cambia la posizione dei worktree" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Tasso di fallimento delle modifiche pari o inferiore alla linea di base dell'era umana per due trimestri consecutivi." +msgid "Changed files" +msgstr "File modificati" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Clona il repository oppure importa una cartella esistente sull'host." msgid "Clone, push, and create PRs." msgstr "Clona, esegui il push e crea PR." +msgid "Cloned into each new workspace" +msgstr "Clonato in ogni nuovo workspace" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Clonato in {0}" @@ -4244,6 +4281,10 @@ msgstr "Impossibile creare il progetto" msgid "Could not create project. Please try a different name or check the logs." msgstr "Impossibile creare il progetto. Prova con un altro nome o controlla i log." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Impossibile creare il progetto: {0}" + msgid "Could not delete account" msgstr "Impossibile eliminare l'account" @@ -4638,6 +4679,12 @@ msgstr "Crea PR" msgid "Create PR failed: {0}" msgstr "Creazione della PR non riuscita: {0}" +msgid "Create project" +msgstr "Crea progetto" + +msgid "Create project with multiple folders" +msgstr "Crea progetto con più cartelle" + msgid "Create pull request" msgstr "Crea la pull request" @@ -4757,6 +4804,9 @@ msgstr "Creazione del worktree" msgid "Creating..." msgstr "Creazione..." +msgid "Creating…" +msgstr "Creazione…" + msgid "Creative Manager, Valid" msgstr "Responsabile creativo, Valid" @@ -4987,6 +5037,9 @@ msgstr "Eliminare “{title}”?" msgid "Delete {0}?" msgstr "Eliminare {0}?" +msgid "Delete {groupName}?" +msgstr "Eliminare {groupName}?" + msgid "Delete {name}?" msgstr "Eliminare {name}?" @@ -5625,6 +5678,9 @@ msgstr "Ogni agente gira nel proprio worktree Git isolato. Niente conflitti di m msgid "Each attachment is at most {0} MB." msgstr "Ogni allegato può essere al massimo {0} MB." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Ogni cartella esegue i propri comandi di setup, teardown ed esecuzione, in questo ordine." + msgid "Each in its own new tab" msgstr "Ognuno in una nuova scheda" @@ -6787,6 +6843,9 @@ msgstr "Cartella" msgid "Folder links" msgstr "Link alle cartelle" +msgid "Folder name" +msgstr "Nome della cartella" + msgid "Folder view" msgstr "Vista a cartelle" @@ -7954,6 +8013,9 @@ msgstr "Item" msgid "Its own branch and copy of the files" msgstr "Un branch e una copia dei file tutti suoi" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Le sue cartelle sorgente restano su questo dispositivo come progetti a sé stanti." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "I suoi workspace e terminali non sono disponibili finché non si riavvia — usa il menu della barra di stato di Superset > Servizio host > Riavvia." @@ -8474,6 +8536,9 @@ msgstr "Caricamento del diff…" msgid "Loading files..." msgstr "Caricamento dei file..." +msgid "Loading folders…" +msgstr "Caricamento delle cartelle…" + msgid "Loading fonts..." msgstr "Caricamento dei font..." @@ -8630,6 +8695,9 @@ msgstr "Imposta come predefinito" msgid "Make default — launch new terminals and agents on this account." msgstr "Imposta come predefinito — avvia i nuovi terminali e agenti su questo account." +msgid "Make primary" +msgstr "Imposta come principale" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Assicurati che il dispositivo sia attivo, online e che Superset sia in esecuzione." @@ -9083,6 +9151,9 @@ msgstr "Il mio agente" msgid "My Tasks" msgstr "I miei task" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Macchinista Misterioso" @@ -9310,6 +9381,12 @@ msgstr "nuovi creatori di workspace" msgid "New workspace in {name}" msgstr "Nuovo workspace in {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "I nuovi workspace non effettueranno più il checkout delle sue cartelle insieme. I workspace esistenti mantengono i propri checkout e nessun repository viene eliminato dal disco." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "I nuovi workspace smetteranno di effettuare il checkout di questo repository. I workspace esistenti lo mantengono e nulla viene eliminato dal disco." + msgid "New-user activation" msgstr "Attivazione nuovi utenti" @@ -11095,6 +11172,9 @@ msgstr "Scegli una directory padre" msgid "Pick a project location" msgstr "Scegli una posizione per il progetto" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Scegli un repository su {hostName} da aggiungere a questo progetto." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Scegli un suono o aggiungine uno tuo. L'audio personalizzato supporta .mp3, .wav e .ogg." @@ -11392,6 +11472,10 @@ msgstr "Il prezzo dipende dal tuo team e dal caso d'uso. Contattaci e preparerem msgid "Primary" msgstr "Primario" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Principale" + msgid "Primary Key" msgstr "Chiave primaria" @@ -12011,16 +12095,24 @@ msgstr "Workspace remoto — dispositivo offline" msgid "Remove" msgstr "Rimuovi" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Rimuovi {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Rimuovere {0} da questo progetto?" + msgid "Remove {label}?" msgstr "Rimuovere {label}?" msgid "Remove attachment" msgstr "Rimuovi allegato" +msgid "Remove folder" +msgstr "Rimuovi cartella" + msgid "Remove from Queue" msgstr "Rimuovi dalla coda" @@ -12112,6 +12204,9 @@ msgstr "Rinomina non riuscita" msgid "Rename failed: {0}" msgstr "Rinomina non riuscita: {0}" +msgid "Rename folder" +msgstr "Rinomina cartella" + msgid "Rename group" msgstr "Rinomina il gruppo" @@ -12219,6 +12314,9 @@ msgstr "Repository" msgid "Repositories accessible through the GitHub App installation." msgstr "Repository accessibili tramite l'installazione della GitHub App." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repository di cui viene effettuato il checkout in ogni workspace di questo progetto. Quello principale è quello usato dagli strumenti per repository singoli." + msgid "Repositories refreshed" msgstr "Repository aggiornati" @@ -13144,6 +13242,12 @@ msgstr "Seleziona un progetto per vedere le pull request." msgid "Select a pull request to preview it here." msgstr "Seleziona una pull request per vederne l'anteprima qui." +msgid "Select a repository to add" +msgstr "Seleziona un repository da aggiungere" + +msgid "Select a source folder" +msgstr "Seleziona una cartella sorgente" + msgid "Select a team" msgstr "Seleziona un team" @@ -13993,6 +14097,13 @@ msgstr "Origine" msgid "Source Available" msgstr "Codice disponibile" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Cartella sorgente di {0}" + +msgid "Source folders" +msgstr "Cartelle sorgente" + msgid "Source path (not an import)" msgstr "Percorso sorgente (non un import)" @@ -14939,6 +15050,9 @@ msgstr "Impossibile risolvere questo endpoint." msgid "That endpoint is not allowed." msgstr "Questo endpoint non è consentito." +msgid "That folder is already in the list" +msgstr "Quella cartella è già nell'elenco" + msgid "That handle is taken." msgstr "Questo handle è già in uso." @@ -15153,6 +15267,9 @@ msgstr "La pagina che cerchi non esiste." msgid "The people behind Superset." msgstr "Le persone dietro Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "La cartella principale non può essere rimossa. Imposta prima un'altra cartella come principale." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Il checkout del progetto così com'è: file e branch sono condivisi" @@ -15402,6 +15519,9 @@ msgstr "Ci sta mettendo più del solito. La sessione potrebbe essere già pronta msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Ci sta mettendo più del solito. Il workspace potrebbe essere già pronto — ricaricare può recuperarlo." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "È la directory in cui viene effettuato il checkout del repository all'interno di ogni workspace." + msgid "This issue link is invalid." msgstr "Questo link alla issue non è valido." @@ -15463,6 +15583,9 @@ msgstr "Questa porta è ancora in uso" msgid "This profile is busy right now" msgstr "Questo profilo è molto richiesto in questo momento" +msgid "This project has no folders on {hostName} yet." +msgstr "Questo progetto non ha ancora cartelle su {hostName}." + msgid "This project is no longer available on your devices." msgstr "Questo progetto non è più disponibile sui tuoi dispositivi." @@ -16290,6 +16413,9 @@ msgstr "Usa per i nuovi agenti" msgid "Use global default" msgstr "Usa il valore predefinito globale" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Usa lettere, cifre, punti, trattini o trattini bassi, iniziando con una lettera o una cifra." + msgid "Use Superset on mobile" msgstr "Usa Superset sul cellulare" @@ -16779,6 +16905,9 @@ msgstr "Dove sono i team seri" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Dove iniziano le sessioni: la home, una pagina di confronto, la documentazione, un post o una voce del changelog. I contenuti che si posizionano compaiono qui prima di comparire nelle registrazioni." +msgid "Where the container holding this project's checkouts is created." +msgstr "Dove viene creato il contenitore che raccoglie i checkout di questo progetto." + msgid "Where the industry is, honestly" msgstr "Dov'è il settore, onestamente" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 7016498cef0..e74136fc937 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· {updatedAgo}に更新" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "「{0}」を追加できませんでした: {1}。それより前に追加されたソースフォルダは保持されています — このフォルダを削除するか問題を解決して、もう一度作成してください。" + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "「{0}」を削除しました" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received}/{total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {ソースフォルダ #件} other {ソースフォルダ #件}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {#件のオートメーションを再試行中} other {#件のオートメーションを再試行中}}" @@ -1381,6 +1389,7 @@ msgstr "アクション" msgid "Actions" msgstr "アクション" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "{0}の操作" @@ -1438,6 +1447,9 @@ msgstr "プロジェクトを追加するとGitHub issueが表示されます。 msgid "Add a project to see pull requests." msgstr "プロジェクトを追加するとプルリクエストが表示されます。" +msgid "Add a repository folder" +msgstr "リポジトリフォルダを追加" + msgid "Add account" msgstr "アカウントを追加" @@ -1492,6 +1504,9 @@ msgstr "ファイルやアプリなどを追加" msgid "Add files, apps, and more (@)" msgstr "ファイルやアプリなどを追加(@)" +msgid "Add folder" +msgstr "フォルダを追加" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "issue #{0}をワークスペースに追加" @@ -1535,6 +1550,12 @@ msgstr "テストのない危険なコードにテストを追加…" msgid "Add tests where they're missing" msgstr "テストが不足している箇所に追加" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "このプロジェクトが扱うリポジトリをソースフォルダとして追加します。すべてのワークスペースにそのすべてがチェックアウトされます。" + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "このプロジェクトが扱うリポジトリを追加します。最初のものがプライマリになります。" + msgid "Add to Dictionary" msgstr "辞書に追加" @@ -1547,6 +1568,10 @@ msgstr "ワークスペースに追加" msgid "Add your own terminal agent to this device." msgstr "このデバイスに独自のターミナルエージェントを追加します。" +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} をこのプロジェクトに追加しました" + msgid "Added {added} files to {where}" msgstr "{added}件のファイルを{where}に追加しました" @@ -1973,6 +1998,9 @@ msgstr "匿名の組立工" msgid "another device" msgstr "別のデバイス" +msgid "Another folder in this project already has that name." +msgstr "このプロジェクトには同じ名前のフォルダがすでにあります。" + msgid "Answered" msgstr "回答済み" @@ -2964,6 +2992,9 @@ msgstr "思考プロセス" msgid "Change avatar" msgstr "アバターを変更" +msgid "Change folder" +msgstr "フォルダを変更" + msgid "Change location" msgstr "場所を変更" @@ -2998,6 +3029,9 @@ msgstr "ワークツリーの場所を変更" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "変更失敗率が2四半期連続で人間の時代の基準値以下に収まる。" +msgid "Changed files" +msgstr "変更されたファイル" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "リポジトリをクローンするか、ホスト上の既存フォル msgid "Clone, push, and create PRs." msgstr "クローン、プッシュ、PRの作成。" +msgid "Cloned into each new workspace" +msgstr "新しいワークスペースごとにクローンされます" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "{0}にクローンしました" @@ -4244,6 +4281,10 @@ msgstr "プロジェクトを作成できませんでした" msgid "Could not create project. Please try a different name or check the logs." msgstr "プロジェクトを作成できませんでした。別の名前を試すか、ログを確認してください。" +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "プロジェクトを作成できませんでした: {0}" + msgid "Could not delete account" msgstr "アカウントを削除できませんでした" @@ -4638,6 +4679,12 @@ msgstr "PRを作成" msgid "Create PR failed: {0}" msgstr "PRの作成に失敗しました: {0}" +msgid "Create project" +msgstr "プロジェクトを作成" + +msgid "Create project with multiple folders" +msgstr "複数フォルダのプロジェクトを作成" + msgid "Create pull request" msgstr "プルリクエストを作成" @@ -4757,6 +4804,9 @@ msgstr "ワークツリーを作成中" msgid "Creating..." msgstr "作成中..." +msgid "Creating…" +msgstr "作成中…" + msgid "Creative Manager, Valid" msgstr "クリエイティブマネージャー、Valid" @@ -4987,6 +5037,9 @@ msgstr "「{title}」を削除しますか?" msgid "Delete {0}?" msgstr "{0}を削除しますか?" +msgid "Delete {groupName}?" +msgstr "{groupName} を削除しますか?" + msgid "Delete {name}?" msgstr "{name}を削除しますか?" @@ -5625,6 +5678,9 @@ msgstr "各エージェントは独立したGitワークツリーで動きます msgid "Each attachment is at most {0} MB." msgstr "添付ファイルは1件あたり最大{0} MBです。" +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "各フォルダは、この順序でそれぞれのセットアップ、ティアダウン、実行コマンドを実行します。" + msgid "Each in its own new tab" msgstr "それぞれ新しいタブで" @@ -6787,6 +6843,9 @@ msgstr "フォルダ" msgid "Folder links" msgstr "フォルダリンク" +msgid "Folder name" +msgstr "フォルダ名" + msgid "Folder view" msgstr "フォルダ表示" @@ -7954,6 +8013,9 @@ msgstr "Item" msgid "Its own branch and copy of the files" msgstr "独自のブランチとファイルのコピー" +msgid "Its source folders stay on this device as projects of their own." +msgstr "ソースフォルダはこのデバイスに、それぞれ独立したプロジェクトとして残ります。" + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "再起動するまで、そのワークスペースとターミナルは利用できません — Supersetのトレイメニューから「ホストサービス」>「再起動」を実行してください。" @@ -8474,6 +8536,9 @@ msgstr "差分を読み込み中…" msgid "Loading files..." msgstr "ファイルを読み込み中..." +msgid "Loading folders…" +msgstr "フォルダを読み込み中…" + msgid "Loading fonts..." msgstr "フォントを読み込み中..." @@ -8630,6 +8695,9 @@ msgstr "デフォルトにする" msgid "Make default — launch new terminals and agents on this account." msgstr "デフォルトにする — 新しいターミナルとエージェントをこのアカウントで起動します。" +msgid "Make primary" +msgstr "プライマリにする" + msgid "Make sure the device is awake, online, and running Superset." msgstr "デバイスがスリープ状態ではなく、オンラインで Superset が起動していることを確認してください。" @@ -9083,6 +9151,9 @@ msgstr "マイエージェント" msgid "My Tasks" msgstr "マイタスク" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "謎の機械工" @@ -9310,6 +9381,12 @@ msgstr "新規ワークスペース作成者" msgid "New workspace in {name}" msgstr "{name} に新規ワークスペース" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "新しいワークスペースではフォルダがまとめてチェックアウトされなくなります。既存のワークスペースのチェックアウトはそのまま残り、リポジトリがディスクから削除されることはありません。" + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "新しいワークスペースではこのリポジトリをチェックアウトしなくなります。既存のワークスペースはそのまま保持され、ディスクからは何も削除されません。" + msgid "New-user activation" msgstr "新規ユーザーのアクティベーション" @@ -11095,6 +11172,9 @@ msgstr "親ディレクトリを選択してください" msgid "Pick a project location" msgstr "プロジェクトの場所を選択してください" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "このプロジェクトに追加するリポジトリを {hostName} 上で選択してください。" + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "サウンドを選ぶか、独自の音声を追加してください。カスタム音声は.mp3、.wav、.ogg対応です。" @@ -11392,6 +11472,10 @@ msgstr "料金はチームの規模と用途によって異なります。ご連 msgid "Primary" msgstr "プライマリ" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "プライマリ" + msgid "Primary Key" msgstr "主キー" @@ -12011,16 +12095,24 @@ msgstr "リモートワークスペース — デバイスがオフライン" msgid "Remove" msgstr "削除" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "{0}を削除" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "{0} をこのプロジェクトから削除しますか?" + msgid "Remove {label}?" msgstr "{label}を削除しますか?" msgid "Remove attachment" msgstr "添付ファイルを削除" +msgid "Remove folder" +msgstr "フォルダを削除" + msgid "Remove from Queue" msgstr "キューから削除" @@ -12112,6 +12204,9 @@ msgstr "名前の変更に失敗しました" msgid "Rename failed: {0}" msgstr "名前の変更に失敗しました: {0}" +msgid "Rename folder" +msgstr "フォルダ名を変更" + msgid "Rename group" msgstr "グループ名を変更" @@ -12219,6 +12314,9 @@ msgstr "リポジトリ" msgid "Repositories accessible through the GitHub App installation." msgstr "GitHub Appのインストールからアクセスできるリポジトリです。" +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "このプロジェクトのすべてのワークスペースにチェックアウトされるリポジトリです。プライマリは単一リポジトリ向けのツールが使用するものです。" + msgid "Repositories refreshed" msgstr "リポジトリを更新しました" @@ -13144,6 +13242,12 @@ msgstr "プロジェクトを選択するとプルリクエストが表示され msgid "Select a pull request to preview it here." msgstr "プルリクエストを選択するとここでプレビューできます。" +msgid "Select a repository to add" +msgstr "追加するリポジトリを選択" + +msgid "Select a source folder" +msgstr "ソースフォルダを選択" + msgid "Select a team" msgstr "チームを選択" @@ -13993,6 +14097,13 @@ msgstr "ソース" msgid "Source Available" msgstr "ソース公開" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "{0} のソースフォルダ" + +msgid "Source folders" +msgstr "ソースフォルダ" + msgid "Source path (not an import)" msgstr "ソースパス(インポートパスではありません)" @@ -14939,6 +15050,9 @@ msgstr "そのエンドポイントを解決できませんでした。" msgid "That endpoint is not allowed." msgstr "そのエンドポイントは許可されていません。" +msgid "That folder is already in the list" +msgstr "そのフォルダはすでにリストにあります" + msgid "That handle is taken." msgstr "そのハンドルは使用済みです。" @@ -15153,6 +15267,9 @@ msgstr "お探しのページは存在しません。" msgid "The people behind Superset." msgstr "Supersetを作っている人たち。" +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "プライマリフォルダは削除できません。先に別のフォルダをプライマリにしてください。" + msgid "The project's checkout as it is: files and branch are shared" msgstr "プロジェクトのチェックアウトをそのまま使用: ファイルとブランチは共有されます" @@ -15402,6 +15519,9 @@ msgstr "通常より時間がかかっています。セッションはすでに msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "通常より時間がかかっています。ワークスペースはすでに準備できているかもしれません — 再読み込みで表示される場合があります。" +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "各ワークスペース内でリポジトリがチェックアウトされるディレクトリです。" + msgid "This issue link is invalid." msgstr "このissueリンクは無効です。" @@ -15463,6 +15583,9 @@ msgstr "このポートはまだ使用中です" msgid "This profile is busy right now" msgstr "このプロフィールは現在混み合っています" +msgid "This project has no folders on {hostName} yet." +msgstr "このプロジェクトには {hostName} 上にまだフォルダがありません。" + msgid "This project is no longer available on your devices." msgstr "このプロジェクトはお使いのデバイスでは利用できなくなりました。" @@ -16290,6 +16413,9 @@ msgstr "新規エージェントに使用" msgid "Use global default" msgstr "グローバルのデフォルトを使用" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "英数字、ドット、ダッシュ、アンダースコアを使用し、先頭は英数字にしてください。" + msgid "Use Superset on mobile" msgstr "モバイルでSupersetを使う" @@ -16779,6 +16905,9 @@ msgstr "本気のチームの現在地" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "セッションの開始地点: ホーム、比較ページ、ドキュメント、記事、変更履歴。ランクインしたコンテンツは、登録に現れる前にここに現れます。" +msgid "Where the container holding this project's checkouts is created." +msgstr "このプロジェクトのチェックアウトを格納するコンテナが作成される場所。" + msgid "Where the industry is, honestly" msgstr "業界の現在地、正直なところ" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 46e38e6b101..22f8a2003aa 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· {updatedAgo}에 업데이트" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "“{0}”을(를) 추가하지 못했습니다: {1}. 그 전에 추가된 소스 폴더는 유지되었습니다 — 해당 폴더를 제거하거나 문제를 해결한 뒤 다시 만드세요." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\"을(를) 삭제했습니다" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{total} 중 {received}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {소스 폴더 #개} other {소스 폴더 #개}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {자동화 #개를 다시 시도하는 중} other {자동화 #개를 다시 시도하는 중}}" @@ -1381,6 +1389,7 @@ msgstr "동작" msgid "Actions" msgstr "동작" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "{0} 작업" @@ -1438,6 +1447,9 @@ msgstr "GitHub 이슈를 보려면 프로젝트를 추가하세요." msgid "Add a project to see pull requests." msgstr "풀 리퀘스트를 보려면 프로젝트를 추가하세요." +msgid "Add a repository folder" +msgstr "리포지토리 폴더 추가" + msgid "Add account" msgstr "계정 추가" @@ -1492,6 +1504,9 @@ msgstr "파일, 앱 등 추가" msgid "Add files, apps, and more (@)" msgstr "파일, 앱 등 추가 (@)" +msgid "Add folder" +msgstr "폴더 추가" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "이슈 #{0}을(를) 워크스페이스에 추가" @@ -1535,6 +1550,12 @@ msgstr "테스트가 없는 가장 위험한 코드에 테스트 추가…" msgid "Add tests where they're missing" msgstr "빠진 테스트 추가하기" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "이 프로젝트가 함께 사용하는 리포지토리를 소스 폴더로 추가하세요. 모든 워크스페이스에 전부 체크아웃됩니다." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "이 프로젝트가 함께 사용하는 리포지토리를 추가하세요. 첫 번째가 기본 폴더입니다." + msgid "Add to Dictionary" msgstr "사전에 추가" @@ -1547,6 +1568,10 @@ msgstr "워크스페이스에 추가" msgid "Add your own terminal agent to this device." msgstr "이 기기에 직접 만든 터미널 에이전트를 추가합니다." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0}을(를) 이 프로젝트에 추가했습니다" + msgid "Added {added} files to {where}" msgstr "{where}에 파일 {added}개를 추가했습니다" @@ -1973,6 +1998,9 @@ msgstr "익명의 조립공" msgid "another device" msgstr "다른 기기" +msgid "Another folder in this project already has that name." +msgstr "이 프로젝트에 같은 이름의 폴더가 이미 있습니다." + msgid "Answered" msgstr "응답함" @@ -2964,6 +2992,9 @@ msgstr "생각의 흐름" msgid "Change avatar" msgstr "아바타 변경" +msgid "Change folder" +msgstr "폴더 변경" + msgid "Change location" msgstr "위치 변경" @@ -2998,6 +3029,9 @@ msgstr "worktree 위치 변경" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "변경 실패율이 두 분기 연속으로 사람이 하던 시절의 기준치 이하를 유지합니다." +msgid "Changed files" +msgstr "변경된 파일" + msgid "Changelog" msgstr "변경 로그" @@ -3334,6 +3368,9 @@ msgstr "리포지토리를 클론하거나 호스트에 있는 기존 폴더를 msgid "Clone, push, and create PRs." msgstr "복제, 푸시 및 PR 생성." +msgid "Cloned into each new workspace" +msgstr "새 워크스페이스마다 클론됩니다" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "{0}(으)로 클론했습니다" @@ -4244,6 +4281,10 @@ msgstr "프로젝트를 만들 수 없습니다" msgid "Could not create project. Please try a different name or check the logs." msgstr "프로젝트를 만들 수 없습니다. 다른 이름을 사용하거나 로그를 확인해 주세요." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "프로젝트를 만들지 못했습니다: {0}" + msgid "Could not delete account" msgstr "계정을 삭제할 수 없습니다" @@ -4638,6 +4679,12 @@ msgstr "PR 만들기" msgid "Create PR failed: {0}" msgstr "PR 생성 실패: {0}" +msgid "Create project" +msgstr "프로젝트 만들기" + +msgid "Create project with multiple folders" +msgstr "여러 폴더로 프로젝트 만들기" + msgid "Create pull request" msgstr "풀 리퀘스트 만들기" @@ -4757,6 +4804,9 @@ msgstr "worktree 만드는 중" msgid "Creating..." msgstr "만드는 중..." +msgid "Creating…" +msgstr "만드는 중…" + msgid "Creative Manager, Valid" msgstr "크리에이티브 매니저, Valid" @@ -4987,6 +5037,9 @@ msgstr "“{title}”을(를) 삭제할까요?" msgid "Delete {0}?" msgstr "{0}을(를) 삭제할까요?" +msgid "Delete {groupName}?" +msgstr "{groupName}을(를) 삭제할까요?" + msgid "Delete {name}?" msgstr "{name}을(를) 삭제할까요?" @@ -5625,6 +5678,9 @@ msgstr "각 에이전트가 격리된 자체 Git worktree에서 실행됩니다. msgid "Each attachment is at most {0} MB." msgstr "첨부 파일은 각각 최대 {0} MB입니다." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "각 폴더는 이 순서대로 자체 설정, 정리, 실행 명령을 실행합니다." + msgid "Each in its own new tab" msgstr "각각 새 탭에서" @@ -6787,6 +6843,9 @@ msgstr "폴더" msgid "Folder links" msgstr "폴더 링크" +msgid "Folder name" +msgstr "폴더 이름" + msgid "Folder view" msgstr "폴더 보기" @@ -7954,6 +8013,9 @@ msgstr "항목" msgid "Its own branch and copy of the files" msgstr "독립된 브랜치와 파일 사본" +msgid "Its source folders stay on this device as projects of their own." +msgstr "소스 폴더는 각각 독립된 프로젝트로 이 기기에 남습니다." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "다시 시작할 때까지 해당 워크스페이스와 터미널을 사용할 수 없습니다. Superset 트레이 메뉴 > 호스트 서비스 > 다시 시작을 사용하세요." @@ -8474,6 +8536,9 @@ msgstr "차이 불러오는 중…" msgid "Loading files..." msgstr "파일을 불러오는 중..." +msgid "Loading folders…" +msgstr "폴더를 불러오는 중…" + msgid "Loading fonts..." msgstr "글꼴 불러오는 중..." @@ -8630,6 +8695,9 @@ msgstr "기본값으로 설정" msgid "Make default — launch new terminals and agents on this account." msgstr "기본값으로 설정 — 새 터미널과 에이전트를 이 계정으로 실행합니다." +msgid "Make primary" +msgstr "기본으로 설정" + msgid "Make sure the device is awake, online, and running Superset." msgstr "기기가 절전 모드가 아니고 온라인 상태이며 Superset이 실행 중인지 확인해 주세요." @@ -9083,6 +9151,9 @@ msgstr "내 에이전트" msgid "My Tasks" msgstr "내 작업" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "수수께끼의 기계공" @@ -9310,6 +9381,12 @@ msgstr "신규 워크스페이스 생성자" msgid "New workspace in {name}" msgstr "{name}에 새 워크스페이스" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "새 워크스페이스에서는 폴더를 함께 체크아웃하지 않습니다. 기존 워크스페이스의 체크아웃은 그대로 유지되며 디스크에서 삭제되는 리포지토리는 없습니다." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "새 워크스페이스에서는 이 리포지토리를 체크아웃하지 않습니다. 기존 워크스페이스는 그대로 유지되며 디스크에서 삭제되는 것은 없습니다." + msgid "New-user activation" msgstr "신규 사용자 활성화" @@ -11095,6 +11172,9 @@ msgstr "상위 디렉터리를 선택하세요" msgid "Pick a project location" msgstr "프로젝트 위치를 선택하세요" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "이 프로젝트에 추가할 리포지토리를 {hostName}에서 선택하세요." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "소리를 고르거나 직접 추가하세요. 사용자 지정 오디오는 .mp3, .wav, .ogg를 지원합니다." @@ -11392,6 +11472,10 @@ msgstr "가격은 팀 규모와 사용 사례에 따라 다릅니다. 문의해 msgid "Primary" msgstr "기본" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "기본" + msgid "Primary Key" msgstr "기본 키" @@ -12011,16 +12095,24 @@ msgstr "원격 워크스페이스 — 기기 오프라인" msgid "Remove" msgstr "제거" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "{0} 제거" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "{0}을(를) 이 프로젝트에서 제거할까요?" + msgid "Remove {label}?" msgstr "{label}을(를) 제거할까요?" msgid "Remove attachment" msgstr "첨부 파일 제거" +msgid "Remove folder" +msgstr "폴더 제거" + msgid "Remove from Queue" msgstr "대기열에서 제거" @@ -12112,6 +12204,9 @@ msgstr "이름 변경 실패" msgid "Rename failed: {0}" msgstr "이름 변경 실패: {0}" +msgid "Rename folder" +msgstr "폴더 이름 변경" + msgid "Rename group" msgstr "그룹 이름 변경" @@ -12219,6 +12314,9 @@ msgstr "리포지토리" msgid "Repositories accessible through the GitHub App installation." msgstr "GitHub App 설치를 통해 접근할 수 있는 리포지토리입니다." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "이 프로젝트의 모든 워크스페이스에 체크아웃되는 리포지토리입니다. 기본 폴더는 단일 리포지토리 도구가 사용하는 폴더입니다." + msgid "Repositories refreshed" msgstr "리포지토리를 새로 고쳤습니다" @@ -13144,6 +13242,12 @@ msgstr "풀 리퀘스트를 보려면 프로젝트를 선택하세요." msgid "Select a pull request to preview it here." msgstr "풀 리퀘스트를 선택하면 여기에서 미리 볼 수 있습니다." +msgid "Select a repository to add" +msgstr "추가할 리포지토리 선택" + +msgid "Select a source folder" +msgstr "소스 폴더 선택" + msgid "Select a team" msgstr "팀 선택" @@ -13993,6 +14097,13 @@ msgstr "출처" msgid "Source Available" msgstr "소스 공개" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "{0}의 소스 폴더" + +msgid "Source folders" +msgstr "소스 폴더" + msgid "Source path (not an import)" msgstr "소스 경로 (임포트 아님)" @@ -14939,6 +15050,9 @@ msgstr "해당 엔드포인트를 확인할 수 없습니다." msgid "That endpoint is not allowed." msgstr "해당 엔드포인트는 허용되지 않습니다." +msgid "That folder is already in the list" +msgstr "이미 목록에 있는 폴더입니다" + msgid "That handle is taken." msgstr "이미 사용 중인 핸들입니다." @@ -15153,6 +15267,9 @@ msgstr "찾으시는 페이지가 존재하지 않습니다." msgid "The people behind Superset." msgstr "Superset을 만드는 사람들." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "기본 폴더는 제거할 수 없습니다. 먼저 다른 폴더를 기본으로 설정하세요." + msgid "The project's checkout as it is: files and branch are shared" msgstr "프로젝트의 체크아웃을 그대로 사용: 파일과 브랜치가 공유됩니다" @@ -15402,6 +15519,9 @@ msgstr "평소보다 오래 걸리고 있습니다. 세션이 이미 준비되 msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "평소보다 오래 걸리고 있습니다. 워크스페이스가 이미 준비되었을 수 있으니 다시 로드하면 반영될 수 있습니다." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "각 워크스페이스에서 리포지토리가 체크아웃되는 디렉터리입니다." + msgid "This issue link is invalid." msgstr "이 이슈 링크가 올바르지 않습니다." @@ -15463,6 +15583,9 @@ msgstr "이 포트는 아직 사용 중입니다" msgid "This profile is busy right now" msgstr "이 프로필은 지금 이용자가 많습니다" +msgid "This project has no folders on {hostName} yet." +msgstr "이 프로젝트에는 {hostName}에 아직 폴더가 없습니다." + msgid "This project is no longer available on your devices." msgstr "이 프로젝트는 더 이상 내 기기에서 사용할 수 없습니다." @@ -16290,6 +16413,9 @@ msgstr "새 에이전트에 사용" msgid "Use global default" msgstr "전역 기본값 사용" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "문자, 숫자, 점, 하이픈, 밑줄을 사용하고 문자나 숫자로 시작하세요." + msgid "Use Superset on mobile" msgstr "모바일에서 Superset 사용하기" @@ -16779,6 +16905,9 @@ msgstr "진지한 팀들이 있는 곳" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "세션이 시작되는 곳: 홈, 비교 페이지, 문서, 글, 변경 로그. 순위에 오른 콘텐츠는 가입에 나타나기 전에 여기에 먼저 나타납니다." +msgid "Where the container holding this project's checkouts is created." +msgstr "이 프로젝트의 체크아웃을 담는 컨테이너가 생성되는 위치입니다." + msgid "Where the industry is, honestly" msgstr "업계의 현재 위치, 솔직하게" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index 26afd5be914..68f8a3eed8d 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· bijgewerkt {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "“{0}” kon niet worden toegevoegd: {1}. De eerder toegevoegde bronmappen zijn behouden — verwijder deze map of los het probleem op en maak het project opnieuw aan." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\" verwijderd" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} van {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# bronmap} other {# bronmappen}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {# automatisering opnieuw uitvoeren} other {# automatiseringen opnieuw uitvoeren}}" @@ -1381,6 +1389,7 @@ msgstr "Actie" msgid "Actions" msgstr "Acties" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Acties voor {0}" @@ -1438,6 +1447,9 @@ msgstr "Voeg een project toe om GitHub-issues te zien." msgid "Add a project to see pull requests." msgstr "Voeg een project toe om pull requests te zien." +msgid "Add a repository folder" +msgstr "Een repositorymap toevoegen" + msgid "Add account" msgstr "Account toevoegen" @@ -1492,6 +1504,9 @@ msgstr "Bestanden, apps en meer toevoegen" msgid "Add files, apps, and more (@)" msgstr "Bestanden, apps en meer toevoegen (@)" +msgid "Add folder" +msgstr "Map toevoegen" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Issue #{0} toevoegen aan werkruimte" @@ -1535,6 +1550,12 @@ msgstr "Voeg tests toe voor de riskantste ongeteste code…" msgid "Add tests where they're missing" msgstr "Tests toevoegen waar ze ontbreken" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Voeg de repository's waarmee dit project werkt toe als bronmappen. Elke werkruimte checkt ze allemaal uit." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Voeg de repository's toe waarmee dit project werkt. De eerste is de primaire." + msgid "Add to Dictionary" msgstr "Toevoegen aan woordenboek" @@ -1547,6 +1568,10 @@ msgstr "Toevoegen aan werkruimte" msgid "Add your own terminal agent to this device." msgstr "Voeg je eigen terminalagent toe aan dit apparaat." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} is aan dit project toegevoegd" + msgid "Added {added} files to {where}" msgstr "{added} bestanden toegevoegd aan {where}" @@ -1973,6 +1998,9 @@ msgstr "Anonieme Assembleur" msgid "another device" msgstr "een ander apparaat" +msgid "Another folder in this project already has that name." +msgstr "Een andere map in dit project heeft die naam al." + msgid "Answered" msgstr "Beantwoord" @@ -2964,6 +2992,9 @@ msgstr "Gedachtegang" msgid "Change avatar" msgstr "Avatar wijzigen" +msgid "Change folder" +msgstr "Map wijzigen" + msgid "Change location" msgstr "Locatie wijzigen" @@ -2998,6 +3029,9 @@ msgstr "Worktree-locatie wijzigen" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Het percentage mislukte wijzigingen ligt twee kwartalen op rij op of onder het niveau van het menselijke tijdperk." +msgid "Changed files" +msgstr "Gewijzigde bestanden" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Kloon de repository of importeer een bestaande map op de host." msgid "Clone, push, and create PRs." msgstr "Kloon, push en maak PR’s." +msgid "Cloned into each new workspace" +msgstr "Wordt naar elke nieuwe werkruimte gekloond" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Gekloond naar {0}" @@ -4244,6 +4281,10 @@ msgstr "Kan project niet aanmaken" msgid "Could not create project. Please try a different name or check the logs." msgstr "Kan project niet aanmaken. Probeer een andere naam of bekijk de logs." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Kon het project niet maken: {0}" + msgid "Could not delete account" msgstr "Kan account niet verwijderen" @@ -4638,6 +4679,12 @@ msgstr "PR aanmaken" msgid "Create PR failed: {0}" msgstr "PR aanmaken mislukt: {0}" +msgid "Create project" +msgstr "Project maken" + +msgid "Create project with multiple folders" +msgstr "Project met meerdere mappen maken" + msgid "Create pull request" msgstr "Pull request aanmaken" @@ -4757,6 +4804,9 @@ msgstr "Worktree aanmaken" msgid "Creating..." msgstr "Aanmaken..." +msgid "Creating…" +msgstr "Bezig met maken…" + msgid "Creative Manager, Valid" msgstr "Creatief manager, Valid" @@ -4987,6 +5037,9 @@ msgstr "“{title}” verwijderen?" msgid "Delete {0}?" msgstr "{0} verwijderen?" +msgid "Delete {groupName}?" +msgstr "{groupName} verwijderen?" + msgid "Delete {name}?" msgstr "{name} verwijderen?" @@ -5625,6 +5678,9 @@ msgstr "Elke agent draait in zijn eigen geïsoleerde Git-worktree. Geen merge-co msgid "Each attachment is at most {0} MB." msgstr "Elke bijlage is maximaal {0} MB." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Elke map voert zijn eigen setup-, teardown- en uitvoercommando's uit, in deze volgorde." + msgid "Each in its own new tab" msgstr "Elk in een eigen nieuw tabblad" @@ -6787,6 +6843,9 @@ msgstr "Map" msgid "Folder links" msgstr "Maplinks" +msgid "Folder name" +msgstr "Mapnaam" + msgid "Folder view" msgstr "Mapweergave" @@ -7954,6 +8013,9 @@ msgstr "Item" msgid "Its own branch and copy of the files" msgstr "Een eigen branch en een eigen kopie van de bestanden" +msgid "Its source folders stay on this device as projects of their own." +msgstr "De bronmappen blijven als zelfstandige projecten op dit apparaat staan." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "De werkruimtes en terminals zijn niet beschikbaar tot de service opnieuw start — gebruik het Superset-menu in de menubalk > Hostservice > Opnieuw starten." @@ -8474,6 +8536,9 @@ msgstr "Diff laden…" msgid "Loading files..." msgstr "Bestanden laden..." +msgid "Loading folders…" +msgstr "Mappen laden…" + msgid "Loading fonts..." msgstr "Lettertypen laden..." @@ -8630,6 +8695,9 @@ msgstr "Standaard maken" msgid "Make default — launch new terminals and agents on this account." msgstr "Standaard maken — start nieuwe terminals en agents op dit account." +msgid "Make primary" +msgstr "Als primair instellen" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Zorg dat het apparaat wakker en online is en dat Superset erop draait." @@ -9083,6 +9151,9 @@ msgstr "Mijn agent" msgid "My Tasks" msgstr "Mijn taken" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Mysterieuze Machinist" @@ -9310,6 +9381,12 @@ msgstr "nieuwe makers van werkruimtes" msgid "New workspace in {name}" msgstr "Nieuwe werkruimte in {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Nieuwe werkruimtes checken de mappen niet meer samen uit. Bestaande werkruimtes behouden hun checkouts en er wordt geen repository van schijf verwijderd." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Nieuwe werkruimtes checken deze repository niet meer uit. Bestaande werkruimtes behouden hem en er wordt niets van schijf verwijderd." + msgid "New-user activation" msgstr "Activatie van nieuwe gebruikers" @@ -11095,6 +11172,9 @@ msgstr "Kies een bovenliggende map" msgid "Pick a project location" msgstr "Kies een projectlocatie" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Kies een repository op {hostName} om aan dit project toe te voegen." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Kies een geluid of voeg je eigen toe. Eigen audio ondersteunt .mp3, .wav en .ogg." @@ -11392,6 +11472,10 @@ msgstr "De prijs hangt af van je team en use case. Neem contact op, dan stellen msgid "Primary" msgstr "Primair" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Primair" + msgid "Primary Key" msgstr "Primary key" @@ -12011,16 +12095,24 @@ msgstr "Externe werkruimte — apparaat offline" msgid "Remove" msgstr "Verwijderen" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "{0} verwijderen" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "{0} uit dit project verwijderen?" + msgid "Remove {label}?" msgstr "{label} verwijderen?" msgid "Remove attachment" msgstr "Bijlage verwijderen" +msgid "Remove folder" +msgstr "Map verwijderen" + msgid "Remove from Queue" msgstr "Uit wachtrij halen" @@ -12112,6 +12204,9 @@ msgstr "Naam wijzigen mislukt" msgid "Rename failed: {0}" msgstr "Naam wijzigen mislukt: {0}" +msgid "Rename folder" +msgstr "Map hernoemen" + msgid "Rename group" msgstr "Groepsnaam wijzigen" @@ -12219,6 +12314,9 @@ msgstr "Repository's" msgid "Repositories accessible through the GitHub App installation." msgstr "Repository's die via de GitHub App-installatie bereikbaar zijn." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repository's die in elke werkruimte van dit project worden uitgecheckt. De primaire is degene die tools voor één repository gebruiken." + msgid "Repositories refreshed" msgstr "Repositories vernieuwd" @@ -13144,6 +13242,12 @@ msgstr "Kies een project om pull requests te zien." msgid "Select a pull request to preview it here." msgstr "Kies een pull request om hem hier te bekijken." +msgid "Select a repository to add" +msgstr "Selecteer een repository om toe te voegen" + +msgid "Select a source folder" +msgstr "Selecteer een bronmap" + msgid "Select a team" msgstr "Kies een team" @@ -13993,6 +14097,13 @@ msgstr "Bron" msgid "Source Available" msgstr "Source available" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Bronmap van {0}" + +msgid "Source folders" +msgstr "Bronmappen" + msgid "Source path (not an import)" msgstr "Bronpad (geen import)" @@ -14939,6 +15050,9 @@ msgstr "Dat endpoint kon niet worden omgezet." msgid "That endpoint is not allowed." msgstr "Dat endpoint is niet toegestaan." +msgid "That folder is already in the list" +msgstr "Die map staat al in de lijst" + msgid "That handle is taken." msgstr "Die handle is al bezet." @@ -15153,6 +15267,9 @@ msgstr "De pagina die je zoekt bestaat niet." msgid "The people behind Superset." msgstr "De mensen achter Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "De primaire map kan niet worden verwijderd. Stel eerst een andere map als primair in." + msgid "The project's checkout as it is: files and branch are shared" msgstr "De checkout van het project zoals die is: bestanden en branch worden gedeeld" @@ -15402,6 +15519,9 @@ msgstr "Dit duurt langer dan gewoonlijk. De sessie is mogelijk al klaar — herl msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Dit duurt langer dan gewoonlijk. De werkruimte is mogelijk al klaar — herladen kan hem oppikken." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Dit is de map waarin de repository binnen elke werkruimte wordt uitgecheckt." + msgid "This issue link is invalid." msgstr "Deze link naar een issue is ongeldig." @@ -15463,6 +15583,9 @@ msgstr "Deze poort is nog in gebruik" msgid "This profile is busy right now" msgstr "Dit profiel is momenteel druk bezet" +msgid "This project has no folders on {hostName} yet." +msgstr "Dit project heeft nog geen mappen op {hostName}." + msgid "This project is no longer available on your devices." msgstr "Dit project is niet meer beschikbaar op je apparaten." @@ -16290,6 +16413,9 @@ msgstr "Gebruiken voor nieuwe agents" msgid "Use global default" msgstr "Globale standaard gebruiken" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Gebruik letters, cijfers, punten, streepjes of underscores, beginnend met een letter of cijfer." + msgid "Use Superset on mobile" msgstr "Gebruik Superset op je mobiel" @@ -16779,6 +16905,9 @@ msgstr "Waar serieuze teams staan" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Waar sessies beginnen: de homepage, een vergelijkingspagina, de docs, een post of een changelog-item. Content die rankt verschijnt hier voordat ze in aanmeldingen verschijnt." +msgid "Where the container holding this project's checkouts is created." +msgstr "Waar de container met de checkouts van dit project wordt aangemaakt." + msgid "Where the industry is, honestly" msgstr "Waar de sector staat, eerlijk gezegd" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index f92284c7b7c..a298f633e27 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· zaktualizowano {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "Nie udało się dodać „{0}”: {1}. Foldery źródłowe dodane wcześniej zostały zachowane — usuń ten folder lub napraw problem i utwórz projekt ponownie." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "„{0}” usunięta" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} z {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# folder źródłowy} few {# foldery źródłowe} many {# folderów źródłowych} other {# folderu źródłowego}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Ponawianie # automatyzacji} few {Ponawianie # automatyzacji} many {Ponawianie # automatyzacji} other {Ponawianie # automatyzacji}}" @@ -1381,6 +1389,7 @@ msgstr "Akcja" msgid "Actions" msgstr "Akcje" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Akcje dla {0}" @@ -1438,6 +1447,9 @@ msgstr "Dodaj projekt, aby zobaczyć zgłoszenia GitHub." msgid "Add a project to see pull requests." msgstr "Dodaj projekt, aby zobaczyć pull requesty." +msgid "Add a repository folder" +msgstr "Dodaj folder repozytorium" + msgid "Add account" msgstr "Dodaj konto" @@ -1492,6 +1504,9 @@ msgstr "Dodaj pliki, aplikacje i inne" msgid "Add files, apps, and more (@)" msgstr "Dodaj pliki, aplikacje i inne (@)" +msgid "Add folder" +msgstr "Dodaj folder" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Dodaj zgłoszenie #{0} do obszaru roboczego" @@ -1535,6 +1550,12 @@ msgstr "Dodaj testy do najbardziej ryzykownego nieprzetestowanego kodu…" msgid "Add tests where they're missing" msgstr "Dodaj testy tam, gdzie ich brakuje" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Dodaj repozytoria, z którymi pracuje ten projekt, jako foldery źródłowe. Każdy obszar roboczy pobiera je wszystkie." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Dodaj repozytoria, z którymi pracuje ten projekt. Pierwsze z nich jest główne." + msgid "Add to Dictionary" msgstr "Dodaj do słownika" @@ -1547,6 +1568,10 @@ msgstr "Dodaj do obszaru roboczego" msgid "Add your own terminal agent to this device." msgstr "Dodaj własnego agenta terminalowego na tym urządzeniu." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "Dodano {0} do tego projektu" + msgid "Added {added} files to {where}" msgstr "Dodano {added} plików do {where}" @@ -1973,6 +1998,9 @@ msgstr "Anonimowy Monter" msgid "another device" msgstr "inne urządzenie" +msgid "Another folder in this project already has that name." +msgstr "Inny folder w tym projekcie ma już taką nazwę." + msgid "Answered" msgstr "Odpowiedziano" @@ -2964,6 +2992,9 @@ msgstr "Tok rozumowania" msgid "Change avatar" msgstr "Zmień awatar" +msgid "Change folder" +msgstr "Zmień folder" + msgid "Change location" msgstr "Zmień lokalizację" @@ -2998,6 +3029,9 @@ msgstr "Zmień lokalizację worktree" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Wskaźnik nieudanych zmian na poziomie ery ludzkiej lub niższy przez dwa kolejne kwartały." +msgid "Changed files" +msgstr "Zmienione pliki" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Sklonuj repozytorium albo zaimportuj istniejący folder na hoście." msgid "Clone, push, and create PRs." msgstr "Klonuj, wysyłaj zmiany i twórz PR." +msgid "Cloned into each new workspace" +msgstr "Klonowany do każdego nowego obszaru roboczego" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Sklonowano do {0}" @@ -4244,6 +4281,10 @@ msgstr "Nie udało się utworzyć projektu" msgid "Could not create project. Please try a different name or check the logs." msgstr "Nie udało się utworzyć projektu. Spróbuj innej nazwy lub sprawdź logi." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Nie udało się utworzyć projektu: {0}" + msgid "Could not delete account" msgstr "Nie udało się usunąć konta" @@ -4638,6 +4679,12 @@ msgstr "Utwórz PR" msgid "Create PR failed: {0}" msgstr "Nie udało się utworzyć PR: {0}" +msgid "Create project" +msgstr "Utwórz projekt" + +msgid "Create project with multiple folders" +msgstr "Utwórz projekt z wieloma folderami" + msgid "Create pull request" msgstr "Utwórz pull request" @@ -4757,6 +4804,9 @@ msgstr "Tworzenie worktree" msgid "Creating..." msgstr "Tworzenie..." +msgid "Creating…" +msgstr "Tworzenie…" + msgid "Creative Manager, Valid" msgstr "Menedżer kreatywny, Valid" @@ -4987,6 +5037,9 @@ msgstr "Usunąć „{title}”?" msgid "Delete {0}?" msgstr "Usunąć {0}?" +msgid "Delete {groupName}?" +msgstr "Usunąć {groupName}?" + msgid "Delete {name}?" msgstr "Usunąć {name}?" @@ -5625,6 +5678,9 @@ msgstr "Każdy agent działa we własnym izolowanym worktree gita. Żadnych konf msgid "Each attachment is at most {0} MB." msgstr "Każdy załącznik może mieć maksymalnie {0} MB." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Każdy folder uruchamia własne polecenia konfiguracji, sprzątania i uruchamiania w tej kolejności." + msgid "Each in its own new tab" msgstr "Każde we własnej nowej karcie" @@ -6787,6 +6843,9 @@ msgstr "Folder" msgid "Folder links" msgstr "Linki do folderów" +msgid "Folder name" +msgstr "Nazwa folderu" + msgid "Folder view" msgstr "Widok folderów" @@ -7954,6 +8013,9 @@ msgstr "Element" msgid "Its own branch and copy of the files" msgstr "Własna gałąź i własna kopia plików" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Jego foldery źródłowe pozostają na tym urządzeniu jako osobne projekty." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Jej obszary robocze i terminale są niedostępne do czasu restartu — użyj menu zasobnika Superset > Usługa hosta > Uruchom ponownie." @@ -8474,6 +8536,9 @@ msgstr "Wczytywanie różnic…" msgid "Loading files..." msgstr "Wczytywanie plików..." +msgid "Loading folders…" +msgstr "Wczytywanie folderów…" + msgid "Loading fonts..." msgstr "Wczytywanie czcionek..." @@ -8630,6 +8695,9 @@ msgstr "Ustaw jako domyślne" msgid "Make default — launch new terminals and agents on this account." msgstr "Ustaw jako domyślne — uruchamiaj nowe terminale i agentów na tym koncie." +msgid "Make primary" +msgstr "Ustaw jako główny" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Upewnij się, że urządzenie jest wybudzone, ma dostęp do internetu i działa na nim Superset." @@ -9083,6 +9151,9 @@ msgstr "Mój agent" msgid "My Tasks" msgstr "Moje zadania" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Tajemniczy Tokarz" @@ -9310,6 +9381,12 @@ msgstr "nowi twórcy obszarów roboczych" msgid "New workspace in {name}" msgstr "Nowy obszar roboczy w {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Nowe obszary robocze nie będą już pobierać jego folderów razem. Istniejące obszary robocze zachowują swoje kopie, a z dysku nie zostanie usunięte żadne repozytorium." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Nowe obszary robocze przestaną pobierać to repozytorium. Istniejące obszary robocze je zachowują i nic nie zostanie usunięte z dysku." + msgid "New-user activation" msgstr "Aktywacja nowych użytkowników" @@ -11095,6 +11172,9 @@ msgstr "Wybierz katalog nadrzędny" msgid "Pick a project location" msgstr "Wybierz lokalizację projektu" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Wybierz repozytorium na {hostName}, które chcesz dodać do tego projektu." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Wybierz dźwięk albo dodaj własny. Obsługiwane formaty własnych plików: .mp3, .wav i .ogg." @@ -11392,6 +11472,10 @@ msgstr "Cena zależy od zespołu i sposobu użycia. Skontaktuj się z nami, a pr msgid "Primary" msgstr "Główny" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Główny" + msgid "Primary Key" msgstr "Klucz główny" @@ -12011,16 +12095,24 @@ msgstr "Zdalny obszar roboczy — urządzenie offline" msgid "Remove" msgstr "Usuń" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Usuń {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Usunąć {0} z tego projektu?" + msgid "Remove {label}?" msgstr "Usunąć {label}?" msgid "Remove attachment" msgstr "Usuń załącznik" +msgid "Remove folder" +msgstr "Usuń folder" + msgid "Remove from Queue" msgstr "Usuń z kolejki" @@ -12112,6 +12204,9 @@ msgstr "Zmiana nazwy nie powiodła się" msgid "Rename failed: {0}" msgstr "Zmiana nazwy nie powiodła się: {0}" +msgid "Rename folder" +msgstr "Zmień nazwę folderu" + msgid "Rename group" msgstr "Zmień nazwę grupy" @@ -12219,6 +12314,9 @@ msgstr "Repozytoria" msgid "Repositories accessible through the GitHub App installation." msgstr "Repozytoria dostępne przez instalację aplikacji GitHub." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repozytoria pobierane do każdego obszaru roboczego tego projektu. Główne jest tym, którego używają narzędzia obsługujące pojedyncze repozytorium." + msgid "Repositories refreshed" msgstr "Odświeżono repozytoria" @@ -13144,6 +13242,12 @@ msgstr "Wybierz projekt, aby zobaczyć pull requesty." msgid "Select a pull request to preview it here." msgstr "Wybierz pull request, aby zobaczyć tu podgląd." +msgid "Select a repository to add" +msgstr "Wybierz repozytorium do dodania" + +msgid "Select a source folder" +msgstr "Wybierz folder źródłowy" + msgid "Select a team" msgstr "Wybierz zespół" @@ -13993,6 +14097,13 @@ msgstr "Źródło" msgid "Source Available" msgstr "Kod dostępny" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Folder źródłowy projektu {0}" + +msgid "Source folders" +msgstr "Foldery źródłowe" + msgid "Source path (not an import)" msgstr "Ścieżka źródłowa (nie import)" @@ -14939,6 +15050,9 @@ msgstr "Nie udało się rozpoznać tego punktu końcowego." msgid "That endpoint is not allowed." msgstr "Ten punkt końcowy jest niedozwolony." +msgid "That folder is already in the list" +msgstr "Ten folder jest już na liście" + msgid "That handle is taken." msgstr "Ten pseudonim jest zajęty." @@ -15153,6 +15267,9 @@ msgstr "Szukana strona nie istnieje." msgid "The people behind Superset." msgstr "Ludzie stojący za Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "Nie można usunąć głównego folderu. Najpierw ustaw jako główny inny folder." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Checkout projektu bez zmian: pliki i gałąź są współdzielone" @@ -15402,6 +15519,9 @@ msgstr "Trwa to dłużej niż zwykle. Sesja może już być gotowa — przełado msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Trwa to dłużej niż zwykle. Obszar roboczy może już być gotowy — przeładowanie może go podchwycić." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "To katalog, do którego repozytorium jest pobierane w każdym obszarze roboczym." + msgid "This issue link is invalid." msgstr "Ten link do zgłoszenia jest nieprawidłowy." @@ -15463,6 +15583,9 @@ msgstr "Ten port jest nadal używany" msgid "This profile is busy right now" msgstr "Ten profil jest teraz przeciążony" +msgid "This project has no folders on {hostName} yet." +msgstr "Ten projekt nie ma jeszcze folderów na {hostName}." + msgid "This project is no longer available on your devices." msgstr "Ten projekt nie jest już dostępny na Twoich urządzeniach." @@ -16290,6 +16413,9 @@ msgstr "Używaj dla nowych agentów" msgid "Use global default" msgstr "Użyj domyślnego globalnego" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Używaj liter, cyfr, kropek, myślników lub podkreśleń, zaczynając od litery lub cyfry." + msgid "Use Superset on mobile" msgstr "Korzystaj z Superset na telefonie" @@ -16779,6 +16905,9 @@ msgstr "Tu są poważne zespoły" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Gdzie zaczynają się sesje: strona główna, strona porównania, dokumentacja, wpis lub pozycja changelogu. Treści, które się pozycjonują, pojawiają się tutaj, zanim pojawią się w rejestracjach." +msgid "Where the container holding this project's checkouts is created." +msgstr "Gdzie powstaje kontener z kopiami roboczymi tego projektu." + msgid "Where the industry is, honestly" msgstr "Gdzie naprawdę jest branża" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index a486c9b234b..48550e3b40c 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· atualizada {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "Não foi possível adicionar “{0}”: {1}. As pastas de origem adicionadas antes foram mantidas — remova essa pasta ou corrija o problema e crie novamente." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\" excluída" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} de {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# pasta de origem} other {# pastas de origem}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Repetindo # automação} other {Repetindo # automações}}" @@ -1381,6 +1389,7 @@ msgstr "Ação" msgid "Actions" msgstr "Ações" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Ações de {0}" @@ -1438,6 +1447,9 @@ msgstr "Adicione um projeto para ver as issues do GitHub." msgid "Add a project to see pull requests." msgstr "Adicione um projeto para ver os pull requests." +msgid "Add a repository folder" +msgstr "Adicionar uma pasta de repositório" + msgid "Add account" msgstr "Adicionar conta" @@ -1492,6 +1504,9 @@ msgstr "Adicionar arquivos, apps e mais" msgid "Add files, apps, and more (@)" msgstr "Adicionar arquivos, apps e mais (@)" +msgid "Add folder" +msgstr "Adicionar pasta" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Adicionar a issue #{0} à área de trabalho" @@ -1535,6 +1550,12 @@ msgstr "Adicione testes para o código sem testes mais arriscado…" msgid "Add tests where they're missing" msgstr "Adicionar testes onde faltam" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Adicione como pastas de origem os repositórios com os quais este projeto trabalha. Todo espaço de trabalho faz checkout de todos eles." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Adicione os repositórios com os quais este projeto trabalha. O primeiro é o principal." + msgid "Add to Dictionary" msgstr "Adicionar ao dicionário" @@ -1547,6 +1568,10 @@ msgstr "Adicionar à área de trabalho" msgid "Add your own terminal agent to this device." msgstr "Adicione seu próprio agente de terminal a este dispositivo." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} foi adicionado a este projeto" + msgid "Added {added} files to {where}" msgstr "{added} arquivos adicionados a {where}" @@ -1973,6 +1998,9 @@ msgstr "Montador Anônimo" msgid "another device" msgstr "outro dispositivo" +msgid "Another folder in this project already has that name." +msgstr "Outra pasta deste projeto já tem esse nome." + msgid "Answered" msgstr "Respondido" @@ -2964,6 +2992,9 @@ msgstr "Cadeia de raciocínio" msgid "Change avatar" msgstr "Trocar o avatar" +msgid "Change folder" +msgstr "Alterar pasta" + msgid "Change location" msgstr "Alterar local" @@ -2998,6 +3029,9 @@ msgstr "Alterar o local das worktrees" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Taxa de falha por mudança igual ou abaixo da linha de base da era humana por dois trimestres seguidos." +msgid "Changed files" +msgstr "Arquivos alterados" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Clone o repositório ou importe uma pasta existente no host." msgid "Clone, push, and create PRs." msgstr "Clone, envie alterações e crie PRs." +msgid "Cloned into each new workspace" +msgstr "Clonado em cada nova área de trabalho" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Clonado em {0}" @@ -4244,6 +4281,10 @@ msgstr "Não foi possível criar o projeto" msgid "Could not create project. Please try a different name or check the logs." msgstr "Não foi possível criar o projeto. Tente outro nome ou verifique os logs." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Não foi possível criar o projeto: {0}" + msgid "Could not delete account" msgstr "Não foi possível excluir a conta" @@ -4638,6 +4679,12 @@ msgstr "Criar PR" msgid "Create PR failed: {0}" msgstr "Falha ao criar o PR: {0}" +msgid "Create project" +msgstr "Criar projeto" + +msgid "Create project with multiple folders" +msgstr "Criar projeto com várias pastas" + msgid "Create pull request" msgstr "Criar pull request" @@ -4757,6 +4804,9 @@ msgstr "Criando a worktree" msgid "Creating..." msgstr "Criando..." +msgid "Creating…" +msgstr "Criando…" + msgid "Creative Manager, Valid" msgstr "Gerente criativo, Valid" @@ -4987,6 +5037,9 @@ msgstr "Excluir “{title}”?" msgid "Delete {0}?" msgstr "Excluir {0}?" +msgid "Delete {groupName}?" +msgstr "Excluir {groupName}?" + msgid "Delete {name}?" msgstr "Excluir {name}?" @@ -5625,6 +5678,9 @@ msgstr "Cada agente roda na própria worktree isolada do Git. Sem conflito de me msgid "Each attachment is at most {0} MB." msgstr "Cada anexo tem no máximo {0} MB." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Cada pasta executa seus próprios comandos de setup, teardown e execução, nesta ordem." + msgid "Each in its own new tab" msgstr "Cada um em sua própria aba" @@ -6787,6 +6843,9 @@ msgstr "Pasta" msgid "Folder links" msgstr "Links de pasta" +msgid "Folder name" +msgstr "Nome da pasta" + msgid "Folder view" msgstr "Visualização por pastas" @@ -7954,6 +8013,9 @@ msgstr "Item" msgid "Its own branch and copy of the files" msgstr "Sua própria branch e sua própria cópia dos arquivos" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Suas pastas de origem permanecem neste dispositivo como projetos próprios." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "As áreas de trabalho e os terminais dele ficam indisponíveis até reiniciar — use o menu da bandeja do Superset > Serviço do host > Reiniciar." @@ -8474,6 +8536,9 @@ msgstr "Carregando o diff…" msgid "Loading files..." msgstr "Carregando os arquivos..." +msgid "Loading folders…" +msgstr "Carregando pastas…" + msgid "Loading fonts..." msgstr "Carregando as fontes..." @@ -8630,6 +8695,9 @@ msgstr "Tornar padrão" msgid "Make default — launch new terminals and agents on this account." msgstr "Tornar padrão — abre novos terminais e agentes nesta conta." +msgid "Make primary" +msgstr "Definir como principal" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Confira se o dispositivo está ativo, online e com o Superset em execução." @@ -9083,6 +9151,9 @@ msgstr "Meu agente" msgid "My Tasks" msgstr "Minhas tarefas" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Maquinista Misterioso" @@ -9310,6 +9381,12 @@ msgstr "novos criadores de áreas de trabalho" msgid "New workspace in {name}" msgstr "Nova área de trabalho em {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Novas áreas de trabalho deixarão de fazer checkout das pastas em conjunto. As áreas de trabalho existentes mantêm seus checkouts e nenhum repositório é excluído do disco." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Novas áreas de trabalho deixarão de fazer checkout deste repositório. As áreas de trabalho existentes o mantêm e nada é excluído do disco." + msgid "New-user activation" msgstr "Ativação de novos usuários" @@ -11095,6 +11172,9 @@ msgstr "Escolha um diretório pai" msgid "Pick a project location" msgstr "Escolha um local para o projeto" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Escolha um repositório em {hostName} para adicionar a este projeto." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Escolha um som ou adicione o seu. O áudio próprio aceita .mp3, .wav e .ogg." @@ -11392,6 +11472,10 @@ msgstr "O preço depende do seu time e do seu caso de uso. Fale com a gente e mo msgid "Primary" msgstr "Primária" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Principal" + msgid "Primary Key" msgstr "Chave primária" @@ -12011,16 +12095,24 @@ msgstr "Área de trabalho remota — dispositivo offline" msgid "Remove" msgstr "Remover" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Remover {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Remover {0} deste projeto?" + msgid "Remove {label}?" msgstr "Remover {label}?" msgid "Remove attachment" msgstr "Remover anexo" +msgid "Remove folder" +msgstr "Remover pasta" + msgid "Remove from Queue" msgstr "Remover da fila" @@ -12112,6 +12204,9 @@ msgstr "Falha ao renomear" msgid "Rename failed: {0}" msgstr "Falha ao renomear: {0}" +msgid "Rename folder" +msgstr "Renomear pasta" + msgid "Rename group" msgstr "Renomear grupo" @@ -12219,6 +12314,9 @@ msgstr "Repositórios" msgid "Repositories accessible through the GitHub App installation." msgstr "Repositórios acessíveis pela instalação do GitHub App." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Repositórios com checkout em cada área de trabalho deste projeto. O principal é o que as ferramentas de repositório único usam." + msgid "Repositories refreshed" msgstr "Repositórios atualizados" @@ -13144,6 +13242,12 @@ msgstr "Selecione um projeto para ver os pull requests." msgid "Select a pull request to preview it here." msgstr "Selecione um pull request para pré-visualizá-lo aqui." +msgid "Select a repository to add" +msgstr "Selecionar um repositório para adicionar" + +msgid "Select a source folder" +msgstr "Selecionar uma pasta de origem" + msgid "Select a team" msgstr "Selecione uma equipe" @@ -13993,6 +14097,13 @@ msgstr "Origem" msgid "Source Available" msgstr "Código disponível" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Pasta de origem de {0}" + +msgid "Source folders" +msgstr "Pastas de origem" + msgid "Source path (not an import)" msgstr "Caminho do fonte (não é um import)" @@ -14939,6 +15050,9 @@ msgstr "Não foi possível resolver esse endpoint." msgid "That endpoint is not allowed." msgstr "Esse endpoint não é permitido." +msgid "That folder is already in the list" +msgstr "Essa pasta já está na lista" + msgid "That handle is taken." msgstr "Esse apelido já está em uso." @@ -15153,6 +15267,9 @@ msgstr "A página que você procura não existe." msgid "The people behind Superset." msgstr "As pessoas por trás do Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "A pasta principal não pode ser removida. Defina outra pasta como principal primeiro." + msgid "The project's checkout as it is: files and branch are shared" msgstr "O checkout do projeto como está: arquivos e branch são compartilhados" @@ -15402,6 +15519,9 @@ msgstr "Está demorando mais que o normal. A sessão pode já estar pronta — r msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Está demorando mais que o normal. A área de trabalho pode já estar pronta — recarregar pode pegá-la." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Este é o diretório em que o repositório recebe checkout dentro de cada área de trabalho." + msgid "This issue link is invalid." msgstr "Este link de issue é inválido." @@ -15463,6 +15583,9 @@ msgstr "Esta porta ainda está em uso" msgid "This profile is busy right now" msgstr "Este perfil está sobrecarregado agora" +msgid "This project has no folders on {hostName} yet." +msgstr "Este projeto ainda não tem pastas em {hostName}." + msgid "This project is no longer available on your devices." msgstr "Este projeto não está mais disponível nos seus dispositivos." @@ -16290,6 +16413,9 @@ msgstr "Usar para novos agentes" msgid "Use global default" msgstr "Usar o padrão global" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Use letras, dígitos, pontos, hifens ou sublinhados, começando por uma letra ou um dígito." + msgid "Use Superset on mobile" msgstr "Use o Superset no celular" @@ -16779,6 +16905,9 @@ msgstr "Onde os times sérios estão" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Onde as sessões começam: a página inicial, uma página de comparação, a documentação, um post ou uma entrada do changelog. Conteúdo que ranqueia aparece aqui antes de aparecer nos cadastros." +msgid "Where the container holding this project's checkouts is created." +msgstr "Onde é criado o contêiner que reúne os checkouts deste projeto." + msgid "Where the industry is, honestly" msgstr "Onde o setor está, sem rodeios" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index faac2aebb08..b37e3cfebd4 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· обновлено {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "Не удалось добавить «{0}»: {1}. Добавленные ранее исходные папки сохранены — удалите эту папку или устраните проблему и создайте проект снова." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "«{0}» удалена" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} из {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# исходная папка} few {# исходные папки} many {# исходных папок} other {# исходной папки}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Повторяем # автоматизацию} few {Повторяем # автоматизации} many {Повторяем # автоматизаций} other {Повторяем # автоматизации}}" @@ -1381,6 +1389,7 @@ msgstr "Действие" msgid "Actions" msgstr "Действия" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Действия для {0}" @@ -1438,6 +1447,9 @@ msgstr "Добавьте проект, чтобы видеть задачи GitH msgid "Add a project to see pull requests." msgstr "Добавьте проект, чтобы видеть пул-реквесты." +msgid "Add a repository folder" +msgstr "Добавить папку репозитория" + msgid "Add account" msgstr "Добавить аккаунт" @@ -1492,6 +1504,9 @@ msgstr "Добавить файлы, приложения и другое" msgid "Add files, apps, and more (@)" msgstr "Добавить файлы, приложения и другое (@)" +msgid "Add folder" +msgstr "Добавить папку" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Добавить задачу #{0} в рабочую область" @@ -1535,6 +1550,12 @@ msgstr "Добавь тесты для самого рискованного н msgid "Add tests where they're missing" msgstr "Добавить тесты там, где их нет" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Добавьте репозитории, с которыми работает этот проект, как исходные папки. В каждую рабочую область извлекаются все они." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Добавьте репозитории, с которыми работает этот проект. Первый из них будет основным." + msgid "Add to Dictionary" msgstr "Добавить в словарь" @@ -1547,6 +1568,10 @@ msgstr "Добавить в рабочую область" msgid "Add your own terminal agent to this device." msgstr "Добавьте своего терминального агента на это устройство." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} добавлен в этот проект" + msgid "Added {added} files to {where}" msgstr "В {where} добавлено файлов: {added}" @@ -1973,6 +1998,9 @@ msgstr "Анонимный сборщик" msgid "another device" msgstr "другое устройство" +msgid "Another folder in this project already has that name." +msgstr "В этом проекте уже есть папка с таким именем." + msgid "Answered" msgstr "Отвечено" @@ -2964,6 +2992,9 @@ msgstr "Цепочка рассуждений" msgid "Change avatar" msgstr "Сменить аватар" +msgid "Change folder" +msgstr "Сменить папку" + msgid "Change location" msgstr "Изменить расположение" @@ -2998,6 +3029,9 @@ msgstr "Изменить расположение worktree" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Доля неудачных изменений на уровне человеческой эпохи или ниже два квартала подряд." +msgid "Changed files" +msgstr "Изменённые файлы" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Клонируйте репозиторий или импортируй msgid "Clone, push, and create PRs." msgstr "Клонируйте, отправляйте изменения и создавайте PR." +msgid "Cloned into each new workspace" +msgstr "Клонируется в каждую новую рабочую область" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Клонировано в {0}" @@ -4244,6 +4281,10 @@ msgstr "Не удалось создать проект" msgid "Could not create project. Please try a different name or check the logs." msgstr "Не удалось создать проект. Попробуйте другое название или посмотрите логи." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Не удалось создать проект: {0}" + msgid "Could not delete account" msgstr "Не удалось удалить аккаунт" @@ -4638,6 +4679,12 @@ msgstr "Создать PR" msgid "Create PR failed: {0}" msgstr "Не удалось создать PR: {0}" +msgid "Create project" +msgstr "Создать проект" + +msgid "Create project with multiple folders" +msgstr "Создать проект с несколькими папками" + msgid "Create pull request" msgstr "Создать пул-реквест" @@ -4757,6 +4804,9 @@ msgstr "Создание worktree" msgid "Creating..." msgstr "Создание..." +msgid "Creating…" +msgstr "Создание…" + msgid "Creative Manager, Valid" msgstr "Креативный менеджер, Valid" @@ -4987,6 +5037,9 @@ msgstr "Удалить «{title}»?" msgid "Delete {0}?" msgstr "Удалить {0}?" +msgid "Delete {groupName}?" +msgstr "Удалить {groupName}?" + msgid "Delete {name}?" msgstr "Удалить {name}?" @@ -5625,6 +5678,9 @@ msgstr "Каждый агент работает в собственном из msgid "Each attachment is at most {0} MB." msgstr "Каждое вложение — не более {0} МБ." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Каждая папка выполняет свои команды настройки, очистки и запуска в этом порядке." + msgid "Each in its own new tab" msgstr "Каждая в своей новой вкладке" @@ -6787,6 +6843,9 @@ msgstr "Папка" msgid "Folder links" msgstr "Ссылки на папки" +msgid "Folder name" +msgstr "Имя папки" + msgid "Folder view" msgstr "Вид папок" @@ -7954,6 +8013,9 @@ msgstr "Элемент" msgid "Its own branch and copy of the files" msgstr "Собственная ветка и собственная копия файлов" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Его исходные папки остаются на этом устройстве как самостоятельные проекты." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Её рабочие области и терминалы недоступны до перезапуска — откройте меню Superset в трее > Host Service > Restart." @@ -8474,6 +8536,9 @@ msgstr "Загрузка diff…" msgid "Loading files..." msgstr "Загрузка файлов..." +msgid "Loading folders…" +msgstr "Загрузка папок…" + msgid "Loading fonts..." msgstr "Загрузка шрифтов..." @@ -8630,6 +8695,9 @@ msgstr "Сделать основным" msgid "Make default — launch new terminals and agents on this account." msgstr "Сделать основным — запускать новые терминалы и агентов на этом аккаунте." +msgid "Make primary" +msgstr "Сделать основной" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Убедитесь, что устройство не в спящем режиме, подключено к интернету и на нём запущен Superset." @@ -9083,6 +9151,9 @@ msgstr "Мой агент" msgid "My Tasks" msgstr "Мои задачи" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Загадочный станочник" @@ -9310,6 +9381,12 @@ msgstr "новые создатели рабочих областей" msgid "New workspace in {name}" msgstr "Новая рабочая область в {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Новые рабочие области больше не будут извлекать его папки вместе. Существующие рабочие области сохранят свои копии, и ни один репозиторий не будет удалён с диска." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Новые рабочие области перестанут извлекать этот репозиторий. Существующие рабочие области сохранят его, и с диска ничего не удаляется." + msgid "New-user activation" msgstr "Активация новых пользователей" @@ -11095,6 +11172,9 @@ msgstr "Выберите родительскую папку" msgid "Pick a project location" msgstr "Выберите расположение проекта" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Выберите репозиторий на {hostName}, чтобы добавить его в этот проект." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Выберите звук или добавьте свой. Поддерживаются .mp3, .wav и .ogg." @@ -11392,6 +11472,10 @@ msgstr "Цена зависит от команды и сценария испо msgid "Primary" msgstr "Основной" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Основная" + msgid "Primary Key" msgstr "Первичный ключ" @@ -12011,16 +12095,24 @@ msgstr "Удалённая рабочая область — устройств msgid "Remove" msgstr "Удалить" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Удалить {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Удалить {0} из этого проекта?" + msgid "Remove {label}?" msgstr "Удалить {label}?" msgid "Remove attachment" msgstr "Удалить вложение" +msgid "Remove folder" +msgstr "Удалить папку" + msgid "Remove from Queue" msgstr "Убрать из очереди" @@ -12112,6 +12204,9 @@ msgstr "Не удалось переименовать" msgid "Rename failed: {0}" msgstr "Не удалось переименовать: {0}" +msgid "Rename folder" +msgstr "Переименовать папку" + msgid "Rename group" msgstr "Переименовать группу" @@ -12219,6 +12314,9 @@ msgstr "Репозитории" msgid "Repositories accessible through the GitHub App installation." msgstr "Репозитории, доступные через установку GitHub App." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Репозитории, извлекаемые в каждую рабочую область этого проекта. Основной — тот, который используют инструменты для одного репозитория." + msgid "Repositories refreshed" msgstr "Репозитории обновлены" @@ -13144,6 +13242,12 @@ msgstr "Выберите проект, чтобы видеть пул-рекве msgid "Select a pull request to preview it here." msgstr "Выберите пул-реквест, чтобы посмотреть его здесь." +msgid "Select a repository to add" +msgstr "Выберите репозиторий для добавления" + +msgid "Select a source folder" +msgstr "Выберите исходную папку" + msgid "Select a team" msgstr "Выберите команду" @@ -13993,6 +14097,13 @@ msgstr "Источник" msgid "Source Available" msgstr "Исходный код доступен" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Исходная папка проекта {0}" + +msgid "Source folders" +msgstr "Исходные папки" + msgid "Source path (not an import)" msgstr "Путь к исходнику (не импорт)" @@ -14939,6 +15050,9 @@ msgstr "Не удалось разрешить эту конечную точк msgid "That endpoint is not allowed." msgstr "Эта конечная точка не разрешена." +msgid "That folder is already in the list" +msgstr "Эта папка уже есть в списке" + msgid "That handle is taken." msgstr "Этот ник занят." @@ -15153,6 +15267,9 @@ msgstr "Такой страницы не существует." msgid "The people behind Superset." msgstr "Люди, которые делают Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "Основную папку нельзя удалить. Сначала сделайте основной другую папку." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Checkout проекта как есть: файлы и ветка общие" @@ -15402,6 +15519,9 @@ msgstr "Это занимает больше времени, чем обычно msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Это занимает больше времени, чем обычно. Возможно, рабочая область уже готова — перезагрузка может её подхватить." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Это каталог, в который репозиторий извлекается внутри каждой рабочей области." + msgid "This issue link is invalid." msgstr "Эта ссылка на задачу недействительна." @@ -15463,6 +15583,9 @@ msgstr "Этот порт ещё используется" msgid "This profile is busy right now" msgstr "Этот профиль сейчас перегружен" +msgid "This project has no folders on {hostName} yet." +msgstr "В этом проекте пока нет папок на {hostName}." + msgid "This project is no longer available on your devices." msgstr "Этот проект больше недоступен на ваших устройствах." @@ -16290,6 +16413,9 @@ msgstr "Использовать для новых агентов" msgid "Use global default" msgstr "Использовать общий по умолчанию" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Используйте буквы, цифры, точки, дефисы или подчёркивания, начиная с буквы или цифры." + msgid "Use Superset on mobile" msgstr "Используйте Superset на телефоне" @@ -16779,6 +16905,9 @@ msgstr "Где сейчас серьёзные команды" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Где начинаются сессии: главная, страница сравнения, документация, запись или пункт changelog. Контент, который ранжируется, появляется здесь раньше, чем в регистрациях." +msgid "Where the container holding this project's checkouts is created." +msgstr "Где создаётся контейнер, в котором лежат копии этого проекта." + msgid "Where the industry is, honestly" msgstr "Где отрасль на самом деле" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 9150ac2986f..f0898709b4e 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· güncelleme {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "“{0}” eklenemedi: {1}. Daha önce eklenen kaynak klasörler korundu — bu klasörü kaldırın ya da sorunu giderip yeniden oluşturun." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "\"{0}\" silindi" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{total} içinden {received}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# kaynak klasör} other {# kaynak klasör}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {# otomasyon yeniden deneniyor} other {# otomasyon yeniden deneniyor}}" @@ -1381,6 +1389,7 @@ msgstr "Eylem" msgid "Actions" msgstr "Eylemler" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "{0} için eylemler" @@ -1438,6 +1447,9 @@ msgstr "GitHub issue'larını görmek için bir proje ekleyin." msgid "Add a project to see pull requests." msgstr "Pull request'leri görmek için bir proje ekleyin." +msgid "Add a repository folder" +msgstr "Depo klasörü ekle" + msgid "Add account" msgstr "Hesap ekle" @@ -1492,6 +1504,9 @@ msgstr "Dosya, uygulama ve daha fazlasını ekleyin" msgid "Add files, apps, and more (@)" msgstr "Dosya, uygulama ve daha fazlasını ekleyin (@)" +msgid "Add folder" +msgstr "Klasör ekle" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "#{0} issue'sunu çalışma alanına ekle" @@ -1535,6 +1550,12 @@ msgstr "Test edilmemiş en riskli kod için test ekle…" msgid "Add tests where they're missing" msgstr "Eksik olan yerlere test ekle" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Bu projenin birlikte çalıştığı depoları kaynak klasör olarak ekleyin. Her çalışma alanına hepsi çıkarılır." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Bu projenin birlikte çalıştığı depoları ekleyin. İlki birincil olur." + msgid "Add to Dictionary" msgstr "Sözlüğe ekle" @@ -1547,6 +1568,10 @@ msgstr "Çalışma alanına ekle" msgid "Add your own terminal agent to this device." msgstr "Bu cihaza kendi terminal ajanınızı ekleyin." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "{0} bu projeye eklendi" + msgid "Added {added} files to {where}" msgstr "{where} konumuna {added} dosya eklendi" @@ -1973,6 +1998,9 @@ msgstr "Anonim Montajcı" msgid "another device" msgstr "başka bir cihaz" +msgid "Another folder in this project already has that name." +msgstr "Bu projede aynı ada sahip başka bir klasör zaten var." + msgid "Answered" msgstr "Yanıtlandı" @@ -2964,6 +2992,9 @@ msgstr "Düşünce zinciri" msgid "Change avatar" msgstr "Avatarı değiştir" +msgid "Change folder" +msgstr "Klasörü değiştir" + msgid "Change location" msgstr "Konumu değiştir" @@ -2998,6 +3029,9 @@ msgstr "Worktree konumunu değiştir" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Değişiklik hata oranı, iki ardışık çeyrek boyunca insan dönemi taban çizgisinde veya altında kalır." +msgid "Changed files" +msgstr "Değiştirilen dosyalar" + msgid "Changelog" msgstr "Değişiklik günlüğü" @@ -3334,6 +3368,9 @@ msgstr "Depoyu klonlayın veya host'taki mevcut bir klasörü içe aktarın." msgid "Clone, push, and create PRs." msgstr "Klonlayın, gönderin ve PR oluşturun." +msgid "Cloned into each new workspace" +msgstr "Her yeni çalışma alanına klonlanır" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "{0} konumuna klonlandı" @@ -4244,6 +4281,10 @@ msgstr "Proje oluşturulamadı" msgid "Could not create project. Please try a different name or check the logs." msgstr "Proje oluşturulamadı. Lütfen farklı bir ad deneyin veya günlükleri kontrol edin." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Proje oluşturulamadı: {0}" + msgid "Could not delete account" msgstr "Hesap silinemedi" @@ -4638,6 +4679,12 @@ msgstr "PR oluştur" msgid "Create PR failed: {0}" msgstr "PR oluşturulamadı: {0}" +msgid "Create project" +msgstr "Proje oluştur" + +msgid "Create project with multiple folders" +msgstr "Birden çok klasörlü proje oluştur" + msgid "Create pull request" msgstr "Pull request oluştur" @@ -4757,6 +4804,9 @@ msgstr "Worktree oluşturuluyor" msgid "Creating..." msgstr "Oluşturuluyor..." +msgid "Creating…" +msgstr "Oluşturuluyor…" + msgid "Creative Manager, Valid" msgstr "Kreatif Yönetici, Valid" @@ -4987,6 +5037,9 @@ msgstr "“{title}” silinsin mi?" msgid "Delete {0}?" msgstr "{0} silinsin mi?" +msgid "Delete {groupName}?" +msgstr "{groupName} silinsin mi?" + msgid "Delete {name}?" msgstr "{name} silinsin mi?" @@ -5625,6 +5678,9 @@ msgstr "Her ajan kendi yalıtılmış Git worktree'sinde çalışır. Birleştir msgid "Each attachment is at most {0} MB." msgstr "Her ek en fazla {0} MB olabilir." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Her klasör kendi kurulum, kapatma ve çalıştırma komutlarını bu sırayla çalıştırır." + msgid "Each in its own new tab" msgstr "Her biri kendi yeni sekmesinde" @@ -6787,6 +6843,9 @@ msgstr "Klasör" msgid "Folder links" msgstr "Klasör bağlantıları" +msgid "Folder name" +msgstr "Klasör adı" + msgid "Folder view" msgstr "Klasör görünümü" @@ -7954,6 +8013,9 @@ msgstr "Öğe" msgid "Its own branch and copy of the files" msgstr "Kendi dalı ve dosyaların kendi kopyası" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Kaynak klasörleri bu cihazda kendi başına birer proje olarak kalır." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Yeniden başlayana kadar çalışma alanları ve terminalleri kullanılamaz — Superset tepsi menüsünde Host hizmeti > Yeniden başlat yolunu kullanın." @@ -8474,6 +8536,9 @@ msgstr "Fark yükleniyor…" msgid "Loading files..." msgstr "Dosyalar yükleniyor..." +msgid "Loading folders…" +msgstr "Klasörler yükleniyor…" + msgid "Loading fonts..." msgstr "Yazı tipleri yükleniyor..." @@ -8630,6 +8695,9 @@ msgstr "Varsayılan yap" msgid "Make default — launch new terminals and agents on this account." msgstr "Varsayılan yap — yeni terminalleri ve ajanları bu hesapta başlat." +msgid "Make primary" +msgstr "Birincil yap" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Cihazın uyanık, çevrimiçi ve Superset’in çalışır durumda olduğundan emin olun." @@ -9083,6 +9151,9 @@ msgstr "Ajanım" msgid "My Tasks" msgstr "Görevlerim" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Gizemli Makinist" @@ -9310,6 +9381,12 @@ msgstr "yeni çalışma alanı oluşturanlar" msgid "New workspace in {name}" msgstr "{name} içinde yeni çalışma alanı" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Yeni çalışma alanları klasörlerini artık birlikte çıkarmayacak. Mevcut çalışma alanları çıkardıkları kopyaları korur ve diskten hiçbir depo silinmez." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Yeni çalışma alanları bu depoyu artık çıkarmayacak. Mevcut çalışma alanları onu korur ve diskten hiçbir şey silinmez." + msgid "New-user activation" msgstr "Yeni kullanıcı aktivasyonu" @@ -11095,6 +11172,9 @@ msgstr "Bir üst dizin seçin" msgid "Pick a project location" msgstr "Bir proje konumu seçin" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Bu projeye eklemek için {hostName} üzerinde bir depo seçin." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Bir ses seçin veya kendinizinkini ekleyin. Özel ses .mp3, .wav ve .ogg destekler." @@ -11392,6 +11472,10 @@ msgstr "Fiyatlandırma ekibinize ve kullanım senaryonuza bağlıdır. Bize ula msgid "Primary" msgstr "Birincil" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Birincil" + msgid "Primary Key" msgstr "Birincil anahtar" @@ -12011,16 +12095,24 @@ msgstr "Uzak çalışma alanı — cihaz çevrimdışı" msgid "Remove" msgstr "Kaldır" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "{0} kişisini kaldır" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "{0} bu projeden kaldırılsın mı?" + msgid "Remove {label}?" msgstr "{label} kaldırılsın mı?" msgid "Remove attachment" msgstr "Eki kaldır" +msgid "Remove folder" +msgstr "Klasörü kaldır" + msgid "Remove from Queue" msgstr "Kuyruktan çıkar" @@ -12112,6 +12204,9 @@ msgstr "Yeniden adlandırılamadı" msgid "Rename failed: {0}" msgstr "Yeniden adlandırılamadı: {0}" +msgid "Rename folder" +msgstr "Klasörü yeniden adlandır" + msgid "Rename group" msgstr "Grubu yeniden adlandır" @@ -12219,6 +12314,9 @@ msgstr "Depolar" msgid "Repositories accessible through the GitHub App installation." msgstr "GitHub App kurulumu üzerinden erişilebilen depolar." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Bu projenin her çalışma alanına çıkarılan depolar. Birincil olan, tek depolu araçların kullandığı depodur." + msgid "Repositories refreshed" msgstr "Depolar yenilendi" @@ -13144,6 +13242,12 @@ msgstr "Pull request'leri görmek için bir proje seçin." msgid "Select a pull request to preview it here." msgstr "Burada önizlemek için bir pull request seçin." +msgid "Select a repository to add" +msgstr "Eklenecek bir depo seçin" + +msgid "Select a source folder" +msgstr "Bir kaynak klasör seçin" + msgid "Select a team" msgstr "Bir takım seçin" @@ -13993,6 +14097,13 @@ msgstr "Kaynak" msgid "Source Available" msgstr "Kaynağı açık" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "{0} projesinin kaynak klasörü" + +msgid "Source folders" +msgstr "Kaynak klasörler" + msgid "Source path (not an import)" msgstr "Kaynak yolu (içe aktarma değil)" @@ -14939,6 +15050,9 @@ msgstr "Bu uç nokta çözümlenemedi." msgid "That endpoint is not allowed." msgstr "Bu uç noktaya izin verilmiyor." +msgid "That folder is already in the list" +msgstr "Bu klasör zaten listede" + msgid "That handle is taken." msgstr "Bu kullanıcı adı alınmış." @@ -15153,6 +15267,9 @@ msgstr "Aradığınız sayfa mevcut değil." msgid "The people behind Superset." msgstr "Superset'in arkasındaki insanlar." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "Birincil klasör kaldırılamaz. Önce başka bir klasörü birincil yapın." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Projenin checkout'u olduğu gibi: dosyalar ve dal paylaşılır" @@ -15402,6 +15519,9 @@ msgstr "Bu normalden uzun sürüyor. Oturum zaten hazır olabilir — yeniden y msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Bu normalden uzun sürüyor. Çalışma alanı zaten hazır olabilir — yeniden yüklemek onu getirebilir." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Deponun her çalışma alanı içinde çıkarıldığı dizindir." + msgid "This issue link is invalid." msgstr "Bu issue bağlantısı geçersiz." @@ -15463,6 +15583,9 @@ msgstr "Bu port hâlâ kullanımda" msgid "This profile is busy right now" msgstr "Bu profil şu anda yoğun" +msgid "This project has no folders on {hostName} yet." +msgstr "Bu projenin {hostName} üzerinde henüz klasörü yok." + msgid "This project is no longer available on your devices." msgstr "Bu proje artık cihazlarınızda kullanılamıyor." @@ -16290,6 +16413,9 @@ msgstr "Yeni ajanlar için kullan" msgid "Use global default" msgstr "Genel varsayılanı kullan" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Bir harf veya rakamla başlayarak harf, rakam, nokta, tire veya alt çizgi kullanın." + msgid "Use Superset on mobile" msgstr "Superset’i mobilde kullanın" @@ -16779,6 +16905,9 @@ msgstr "Ciddi takımların bulunduğu yer" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Oturumların başladığı yer: ana sayfa, bir karşılaştırma sayfası, dokümantasyon, bir yazı veya bir değişiklik günlüğü kaydı. Sıralamaya giren içerik, kayıtlarda görünmeden önce burada görünür." +msgid "Where the container holding this project's checkouts is created." +msgstr "Bu projenin çıkarılan kopyalarını tutan kapsayıcının oluşturulacağı yer." + msgid "Where the industry is, honestly" msgstr "Sektör dürüstçe nerede" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 4d050477dc7..95d00c11b13 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· cập nhật {updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "Không thể thêm “{0}”: {1}. Các thư mục nguồn thêm trước đó vẫn được giữ — hãy xóa thư mục này hoặc khắc phục rồi tạo lại." + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "Đã xóa \"{0}\"" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received} trên {total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {# thư mục nguồn} other {# thư mục nguồn}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {Đang thử lại # tự động hóa} other {Đang thử lại # tự động hóa}}" @@ -1381,6 +1389,7 @@ msgstr "Hành động" msgid "Actions" msgstr "Hành động" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "Hành động cho {0}" @@ -1438,6 +1447,9 @@ msgstr "Thêm một dự án để xem issue GitHub." msgid "Add a project to see pull requests." msgstr "Thêm một dự án để xem pull request." +msgid "Add a repository folder" +msgstr "Thêm thư mục repository" + msgid "Add account" msgstr "Thêm tài khoản" @@ -1492,6 +1504,9 @@ msgstr "Thêm tệp, ứng dụng và hơn thế" msgid "Add files, apps, and more (@)" msgstr "Thêm tệp, ứng dụng và hơn thế (@)" +msgid "Add folder" +msgstr "Thêm thư mục" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "Thêm issue #{0} vào workspace" @@ -1535,6 +1550,12 @@ msgstr "Thêm test cho phần code rủi ro nhất chưa được test…" msgid "Add tests where they're missing" msgstr "Thêm test ở những chỗ còn thiếu" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "Thêm các repository mà dự án này làm việc cùng làm thư mục nguồn. Mọi workspace đều checkout tất cả chúng." + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "Thêm các repository mà dự án này làm việc cùng. Repository đầu tiên là repository chính." + msgid "Add to Dictionary" msgstr "Thêm vào từ điển" @@ -1547,6 +1568,10 @@ msgstr "Thêm vào workspace" msgid "Add your own terminal agent to this device." msgstr "Thêm agent terminal của riêng bạn vào thiết bị này." +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "Đã thêm {0} vào dự án này" + msgid "Added {added} files to {where}" msgstr "Đã thêm {added} tệp vào {where}" @@ -1973,6 +1998,9 @@ msgstr "Thợ lắp ráp ẩn danh" msgid "another device" msgstr "thiết bị khác" +msgid "Another folder in this project already has that name." +msgstr "Một thư mục khác trong dự án này đã dùng tên đó." + msgid "Answered" msgstr "Đã trả lời" @@ -2964,6 +2992,9 @@ msgstr "Chuỗi suy luận" msgid "Change avatar" msgstr "Đổi ảnh đại diện" +msgid "Change folder" +msgstr "Đổi thư mục" + msgid "Change location" msgstr "Đổi vị trí" @@ -2998,6 +3029,9 @@ msgstr "Đổi vị trí worktree" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "Tỷ lệ thay đổi gây lỗi bằng hoặc thấp hơn mức nền của thời con người trong hai quý liên tiếp." +msgid "Changed files" +msgstr "Tệp đã thay đổi" + msgid "Changelog" msgstr "Changelog" @@ -3334,6 +3368,9 @@ msgstr "Clone repository, hoặc nhập một thư mục có sẵn trên host." msgid "Clone, push, and create PRs." msgstr "Sao chép, đẩy và tạo PR." +msgid "Cloned into each new workspace" +msgstr "Được clone vào mỗi workspace mới" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "Đã clone vào {0}" @@ -4244,6 +4281,10 @@ msgstr "Không thể tạo dự án" msgid "Could not create project. Please try a different name or check the logs." msgstr "Không thể tạo dự án. Hãy thử tên khác hoặc xem log." +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "Không thể tạo dự án: {0}" + msgid "Could not delete account" msgstr "Không thể xóa tài khoản" @@ -4638,6 +4679,12 @@ msgstr "Tạo PR" msgid "Create PR failed: {0}" msgstr "Tạo PR thất bại: {0}" +msgid "Create project" +msgstr "Tạo dự án" + +msgid "Create project with multiple folders" +msgstr "Tạo dự án với nhiều thư mục" + msgid "Create pull request" msgstr "Tạo pull request" @@ -4757,6 +4804,9 @@ msgstr "Đang tạo worktree" msgid "Creating..." msgstr "Đang tạo..." +msgid "Creating…" +msgstr "Đang tạo…" + msgid "Creative Manager, Valid" msgstr "Quản lý sáng tạo, Valid" @@ -4987,6 +5037,9 @@ msgstr "Xóa “{title}”?" msgid "Delete {0}?" msgstr "Xóa {0}?" +msgid "Delete {groupName}?" +msgstr "Xóa {groupName}?" + msgid "Delete {name}?" msgstr "Xóa {name}?" @@ -5625,6 +5678,9 @@ msgstr "Mỗi agent chạy trong git worktree tách biệt của riêng nó. Kh msgid "Each attachment is at most {0} MB." msgstr "Mỗi tệp đính kèm tối đa {0} MB." +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "Mỗi thư mục chạy các lệnh thiết lập, dọn dẹp và chạy của riêng nó, theo thứ tự này." + msgid "Each in its own new tab" msgstr "Mỗi lệnh một tab mới" @@ -6787,6 +6843,9 @@ msgstr "Thư mục" msgid "Folder links" msgstr "Liên kết thư mục" +msgid "Folder name" +msgstr "Tên thư mục" + msgid "Folder view" msgstr "Xem theo thư mục" @@ -7954,6 +8013,9 @@ msgstr "Item" msgid "Its own branch and copy of the files" msgstr "Nhánh riêng và bản sao tệp riêng" +msgid "Its source folders stay on this device as projects of their own." +msgstr "Các thư mục nguồn của nó vẫn ở trên thiết bị này như những dự án riêng." + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "Các workspace và terminal của nó không khả dụng cho đến khi khởi động lại — dùng menu khay Superset > Host Service > Restart." @@ -8474,6 +8536,9 @@ msgstr "Đang tải diff…" msgid "Loading files..." msgstr "Đang tải tệp..." +msgid "Loading folders…" +msgstr "Đang tải thư mục…" + msgid "Loading fonts..." msgstr "Đang tải font..." @@ -8630,6 +8695,9 @@ msgstr "Đặt làm mặc định" msgid "Make default — launch new terminals and agents on this account." msgstr "Đặt làm mặc định — mở terminal và agent mới trên tài khoản này." +msgid "Make primary" +msgstr "Đặt làm chính" + msgid "Make sure the device is awake, online, and running Superset." msgstr "Đảm bảo thiết bị không ở chế độ ngủ, có kết nối mạng và đang chạy Superset." @@ -9083,6 +9151,9 @@ msgstr "Agent của tôi" msgid "My Tasks" msgstr "Tác vụ của tôi" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "Thợ máy bí ẩn" @@ -9310,6 +9381,12 @@ msgstr "người tạo workspace mới" msgid "New workspace in {name}" msgstr "Workspace mới trong {name}" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "Các workspace mới sẽ không còn checkout các thư mục của nó cùng nhau. Các workspace hiện có vẫn giữ bản checkout của mình và không có repository nào bị xóa khỏi ổ đĩa." + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "Các workspace mới sẽ không còn checkout repository này. Các workspace hiện có vẫn giữ nó và không có gì bị xóa khỏi ổ đĩa." + msgid "New-user activation" msgstr "Kích hoạt người dùng mới" @@ -11095,6 +11172,9 @@ msgstr "Hãy chọn một thư mục cha" msgid "Pick a project location" msgstr "Hãy chọn vị trí dự án" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "Chọn một repository trên {hostName} để thêm vào dự án này." + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "Chọn một âm thanh hoặc thêm âm thanh riêng. Âm thanh riêng hỗ trợ .mp3, .wav và .ogg." @@ -11392,6 +11472,10 @@ msgstr "Giá phụ thuộc vào đội ngũ và nhu cầu của bạn. Hãy liê msgid "Primary" msgstr "Chính" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "Chính" + msgid "Primary Key" msgstr "Khóa chính" @@ -12011,16 +12095,24 @@ msgstr "Workspace từ xa — thiết bị ngoại tuyến" msgid "Remove" msgstr "Gỡ" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "Gỡ {0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "Gỡ {0} khỏi dự án này?" + msgid "Remove {label}?" msgstr "Gỡ {label}?" msgid "Remove attachment" msgstr "Xóa tệp đính kèm" +msgid "Remove folder" +msgstr "Gỡ thư mục" + msgid "Remove from Queue" msgstr "Bỏ khỏi hàng đợi" @@ -12112,6 +12204,9 @@ msgstr "Đổi tên thất bại" msgid "Rename failed: {0}" msgstr "Đổi tên thất bại: {0}" +msgid "Rename folder" +msgstr "Đổi tên thư mục" + msgid "Rename group" msgstr "Đổi tên nhóm" @@ -12219,6 +12314,9 @@ msgstr "Repository" msgid "Repositories accessible through the GitHub App installation." msgstr "Các repository truy cập được qua bản cài đặt GitHub App." +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "Các repository được checkout vào mọi workspace của dự án này. Repository chính là repository mà các công cụ một-repository sử dụng." + msgid "Repositories refreshed" msgstr "Đã làm mới repository" @@ -13144,6 +13242,12 @@ msgstr "Chọn một dự án để xem pull request." msgid "Select a pull request to preview it here." msgstr "Chọn một pull request để xem trước ở đây." +msgid "Select a repository to add" +msgstr "Chọn repository để thêm" + +msgid "Select a source folder" +msgstr "Chọn thư mục nguồn" + msgid "Select a team" msgstr "Chọn một nhóm" @@ -13993,6 +14097,13 @@ msgstr "Nguồn" msgid "Source Available" msgstr "Mã nguồn công khai" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "Thư mục nguồn của {0}" + +msgid "Source folders" +msgstr "Thư mục nguồn" + msgid "Source path (not an import)" msgstr "Đường dẫn nguồn (không phải import)" @@ -14939,6 +15050,9 @@ msgstr "Không thể phân giải endpoint đó." msgid "That endpoint is not allowed." msgstr "Endpoint đó không được phép." +msgid "That folder is already in the list" +msgstr "Thư mục đó đã có trong danh sách" + msgid "That handle is taken." msgstr "Tên định danh đó đã có người dùng." @@ -15153,6 +15267,9 @@ msgstr "Trang bạn tìm không tồn tại." msgid "The people behind Superset." msgstr "Những người đứng sau Superset." +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "Không thể gỡ thư mục chính. Hãy đặt một thư mục khác làm chính trước." + msgid "The project's checkout as it is: files and branch are shared" msgstr "Checkout của dự án như hiện có: tệp và nhánh được dùng chung" @@ -15402,6 +15519,9 @@ msgstr "Việc này lâu hơn bình thường. Phiên có thể đã sẵn sàng msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "Việc này lâu hơn bình thường. Workspace có thể đã sẵn sàng — tải lại có thể nhận được nó." +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "Đây là thư mục mà repository được checkout vào bên trong mỗi workspace." + msgid "This issue link is invalid." msgstr "Liên kết issue này không hợp lệ." @@ -15463,6 +15583,9 @@ msgstr "Cổng này vẫn đang được dùng" msgid "This profile is busy right now" msgstr "Hồ sơ này hiện đang quá tải" +msgid "This project has no folders on {hostName} yet." +msgstr "Dự án này chưa có thư mục nào trên {hostName}." + msgid "This project is no longer available on your devices." msgstr "Dự án này không còn khả dụng trên các thiết bị của bạn." @@ -16290,6 +16413,9 @@ msgstr "Dùng cho agent mới" msgid "Use global default" msgstr "Dùng mặc định toàn cục" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "Hãy dùng chữ cái, chữ số, dấu chấm, gạch ngang hoặc gạch dưới, bắt đầu bằng chữ cái hoặc chữ số." + msgid "Use Superset on mobile" msgstr "Dùng Superset trên điện thoại" @@ -16779,6 +16905,9 @@ msgstr "Nơi các đội nghiêm túc đang đứng" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "Nơi phiên bắt đầu: trang chủ, trang so sánh, tài liệu, bài viết hoặc mục changelog. Nội dung có thứ hạng xuất hiện ở đây trước khi xuất hiện trong đăng ký." +msgid "Where the container holding this project's checkouts is created." +msgstr "Nơi tạo container chứa các bản checkout của dự án này." + msgid "Where the industry is, honestly" msgstr "Ngành đang ở đâu, nói thật" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index c3b9c10080e..23808ea5b6e 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· 更新于{updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "无法添加“{0}”:{1}。此前已添加的源文件夹会保留——请移除该文件夹或修复问题后重新创建。" + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "“{0}”已删除" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received}/{total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {#个源文件夹} other {#个源文件夹}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {正在重试#个自动化} other {正在重试#个自动化}}" @@ -1381,6 +1389,7 @@ msgstr "操作" msgid "Actions" msgstr "操作" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "{0}的操作" @@ -1438,6 +1447,9 @@ msgstr "添加项目以查看GitHub议题。" msgid "Add a project to see pull requests." msgstr "添加一个项目来查看拉取请求。" +msgid "Add a repository folder" +msgstr "添加仓库文件夹" + msgid "Add account" msgstr "添加账户" @@ -1492,6 +1504,9 @@ msgstr "添加文件、应用等" msgid "Add files, apps, and more (@)" msgstr "添加文件、应用等(@)" +msgid "Add folder" +msgstr "添加文件夹" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "将议题#{0}添加到工作区" @@ -1535,6 +1550,12 @@ msgstr "给风险最高又没测试的代码补上测试…" msgid "Add tests where they're missing" msgstr "给缺测试的地方补测试" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "把此项目需要用到的仓库添加为源文件夹。每个工作区都会检出全部仓库。" + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "添加此项目需要用到的仓库。第一个是主文件夹。" + msgid "Add to Dictionary" msgstr "添加到词典" @@ -1547,6 +1568,10 @@ msgstr "添加到工作区" msgid "Add your own terminal agent to this device." msgstr "向此设备添加你自己的终端智能体。" +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "已将 {0} 添加到此项目" + msgid "Added {added} files to {where}" msgstr "已向{where}添加{added}个文件" @@ -1973,6 +1998,9 @@ msgstr "匿名装配工" msgid "another device" msgstr "另一台设备" +msgid "Another folder in this project already has that name." +msgstr "此项目中已有同名文件夹。" + msgid "Answered" msgstr "已回答" @@ -2964,6 +2992,9 @@ msgstr "思维链" msgid "Change avatar" msgstr "更换头像" +msgid "Change folder" +msgstr "更改文件夹" + msgid "Change location" msgstr "更改位置" @@ -2998,6 +3029,9 @@ msgstr "更改worktree位置" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "变更失败率连续两个季度不高于人类时代的基线。" +msgid "Changed files" +msgstr "已更改的文件" + msgid "Changelog" msgstr "更新日志" @@ -3334,6 +3368,9 @@ msgstr "克隆仓库,或导入主机上已有的文件夹。" msgid "Clone, push, and create PRs." msgstr "克隆、推送并创建 PR。" +msgid "Cloned into each new workspace" +msgstr "克隆到每个新工作区" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "已克隆到{0}" @@ -4244,6 +4281,10 @@ msgstr "无法创建项目" msgid "Could not create project. Please try a different name or check the logs." msgstr "无法创建项目。请换一个名称,或查看日志。" +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "无法创建项目:{0}" + msgid "Could not delete account" msgstr "无法删除账户" @@ -4638,6 +4679,12 @@ msgstr "创建PR" msgid "Create PR failed: {0}" msgstr "创建PR失败:{0}" +msgid "Create project" +msgstr "创建项目" + +msgid "Create project with multiple folders" +msgstr "创建包含多个文件夹的项目" + msgid "Create pull request" msgstr "创建拉取请求" @@ -4757,6 +4804,9 @@ msgstr "正在创建worktree" msgid "Creating..." msgstr "正在创建..." +msgid "Creating…" +msgstr "正在创建…" + msgid "Creative Manager, Valid" msgstr "创意经理,Valid" @@ -4987,6 +5037,9 @@ msgstr "删除“{title}”?" msgid "Delete {0}?" msgstr "删除{0}?" +msgid "Delete {groupName}?" +msgstr "删除 {groupName}?" + msgid "Delete {name}?" msgstr "删除{name}?" @@ -5625,6 +5678,9 @@ msgstr "每个智能体都跑在自己隔离的Git worktree里。没有合并冲 msgid "Each attachment is at most {0} MB." msgstr "每个附件最大为{0} MB。" +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "每个文件夹按此顺序运行各自的初始化、清理和运行命令。" + msgid "Each in its own new tab" msgstr "每条各占一个新标签页" @@ -6787,6 +6843,9 @@ msgstr "文件夹" msgid "Folder links" msgstr "文件夹链接" +msgid "Folder name" +msgstr "文件夹名称" + msgid "Folder view" msgstr "文件夹视图" @@ -7954,6 +8013,9 @@ msgstr "条目" msgid "Its own branch and copy of the files" msgstr "独立的分支和文件副本" +msgid "Its source folders stay on this device as projects of their own." +msgstr "它的源文件夹会作为各自独立的项目保留在此设备上。" + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "在它重启之前,其工作区和终端都不可用,请使用Superset托盘菜单 > 主机服务 > 重启。" @@ -8474,6 +8536,9 @@ msgstr "正在加载差异…" msgid "Loading files..." msgstr "正在加载文件..." +msgid "Loading folders…" +msgstr "正在加载文件夹…" + msgid "Loading fonts..." msgstr "正在加载字体..." @@ -8630,6 +8695,9 @@ msgstr "设为默认" msgid "Make default — launch new terminals and agents on this account." msgstr "设为默认——新终端和智能体将使用此账户启动。" +msgid "Make primary" +msgstr "设为主文件夹" + msgid "Make sure the device is awake, online, and running Superset." msgstr "请确认设备未休眠、已联网且正在运行 Superset。" @@ -9083,6 +9151,9 @@ msgstr "我的智能体" msgid "My Tasks" msgstr "我的任务" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "神秘机械师" @@ -9310,6 +9381,12 @@ msgstr "新增工作区创建者" msgid "New workspace in {name}" msgstr "在 {name} 中新建工作区" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "新工作区将不再把它的文件夹一起检出。现有工作区会保留各自的检出,磁盘上的仓库不会被删除。" + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "新工作区将不再检出此仓库。现有工作区会保留它,磁盘上的内容不会被删除。" + msgid "New-user activation" msgstr "新用户激活" @@ -11095,6 +11172,9 @@ msgstr "请选择父目录" msgid "Pick a project location" msgstr "请选择项目位置" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "在 {hostName} 上选择要添加到此项目的仓库。" + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "选择一个声音或添加自己的。自定义音频支持.mp3、.wav和.ogg。" @@ -11392,6 +11472,10 @@ msgstr "价格取决于你的团队规模和使用场景。联系我们,我们 msgid "Primary" msgstr "主色" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "主文件夹" + msgid "Primary Key" msgstr "主键" @@ -12011,16 +12095,24 @@ msgstr "远程工作区,设备离线" msgid "Remove" msgstr "移除" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "移除{0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "要将 {0} 从此项目中移除吗?" + msgid "Remove {label}?" msgstr "移除{label}?" msgid "Remove attachment" msgstr "移除附件" +msgid "Remove folder" +msgstr "移除文件夹" + msgid "Remove from Queue" msgstr "移出队列" @@ -12112,6 +12204,9 @@ msgstr "重命名失败" msgid "Rename failed: {0}" msgstr "重命名失败:{0}" +msgid "Rename folder" +msgstr "重命名文件夹" + msgid "Rename group" msgstr "重命名分组" @@ -12219,6 +12314,9 @@ msgstr "仓库" msgid "Repositories accessible through the GitHub App installation." msgstr "通过GitHub App安装可访问的仓库。" +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "为此项目的每个工作区检出的仓库。主文件夹是单仓库工具使用的那一个。" + msgid "Repositories refreshed" msgstr "仓库已刷新" @@ -13144,6 +13242,12 @@ msgstr "选择一个项目来查看拉取请求。" msgid "Select a pull request to preview it here." msgstr "选择一个拉取请求,在这里预览。" +msgid "Select a repository to add" +msgstr "选择要添加的仓库" + +msgid "Select a source folder" +msgstr "选择源文件夹" + msgid "Select a team" msgstr "选择团队" @@ -13993,6 +14097,13 @@ msgstr "来源" msgid "Source Available" msgstr "源码可见" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "{0} 的源文件夹" + +msgid "Source folders" +msgstr "源文件夹" + msgid "Source path (not an import)" msgstr "源码路径(不是导入路径)" @@ -14939,6 +15050,9 @@ msgstr "无法解析该端点。" msgid "That endpoint is not allowed." msgstr "该端点不被允许。" +msgid "That folder is already in the list" +msgstr "该文件夹已在列表中" + msgid "That handle is taken." msgstr "该用户名已被占用。" @@ -15153,6 +15267,9 @@ msgstr "你要找的页面不存在。" msgid "The people behind Superset." msgstr "Superset背后的团队。" +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "无法移除主文件夹。请先将另一个文件夹设为主文件夹。" + msgid "The project's checkout as it is: files and branch are shared" msgstr "按原样使用项目的检出: 文件和分支是共享的" @@ -15402,6 +15519,9 @@ msgstr "这比平时耗时更久。会话可能已经就绪 — 重新加载即 msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "这比平时耗时更久。工作区可能已经就绪 — 重新加载即可接上。" +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "这是仓库在每个工作区中检出的目录。" + msgid "This issue link is invalid." msgstr "此议题链接无效。" @@ -15463,6 +15583,9 @@ msgstr "此端口仍在使用中" msgid "This profile is busy right now" msgstr "此个人主页当前繁忙" +msgid "This project has no folders on {hostName} yet." +msgstr "此项目在 {hostName} 上还没有文件夹。" + msgid "This project is no longer available on your devices." msgstr "这个项目在你的设备上已不可用。" @@ -16290,6 +16413,9 @@ msgstr "用于新智能体" msgid "Use global default" msgstr "使用全局默认值" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "请使用字母、数字、点、短横线或下划线,并以字母或数字开头。" + msgid "Use Superset on mobile" msgstr "在手机上使用 Superset" @@ -16779,6 +16905,9 @@ msgstr "认真的团队都在这里" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "会话的起点:首页、对比页、文档、文章或更新日志。有排名的内容会先出现在这里,然后才体现在注册上。" +msgid "Where the container holding this project's checkouts is created." +msgstr "创建存放此项目检出内容的容器的位置。" + msgid "Where the industry is, honestly" msgstr "行业实际走到了哪里" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index c6418c73b37..2eae89f3b45 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -38,6 +38,11 @@ msgstr ".sh" msgid "· updated {updatedAgo}" msgstr "· 更新於{updatedAgo}" +#. placeholder {0}: result.folder.name +#. placeholder {1}: errorMessage(result.error) +msgid "\"{0}\" could not be added: {1}. The source folders added before it were kept — remove it or fix it and create again." +msgstr "無法新增「{0}」:{1}。先前已新增的來源資料夾會保留——請移除該資料夾或修正問題後重新建立。" + #. placeholder {0}: automation.name msgid "\"{0}\" deleted" msgstr "「{0}」已刪除" @@ -870,6 +875,9 @@ msgstr "{reason}" msgid "{received} of {total}" msgstr "{received}/{total}" +msgid "{repoCount, plural, one {# source folder} other {# source folders}}" +msgstr "{repoCount, plural, one {#個來源資料夾} other {#個來源資料夾}}" + msgid "{retried, plural, one {Retrying # automation} other {Retrying # automations}}" msgstr "{retried, plural, one {正在重試#個自動化} other {正在重試#個自動化}}" @@ -1381,6 +1389,7 @@ msgstr "操作" msgid "Actions" msgstr "操作" +#. placeholder {0}: folder.folder #. placeholder {0}: page.title msgid "Actions for {0}" msgstr "{0}的操作" @@ -1438,6 +1447,9 @@ msgstr "新增專案以檢視GitHub議題。" msgid "Add a project to see pull requests." msgstr "新增一個專案來檢視拉取請求。" +msgid "Add a repository folder" +msgstr "新增儲存庫資料夾" + msgid "Add account" msgstr "新增帳戶" @@ -1492,6 +1504,9 @@ msgstr "新增檔案、應用等" msgid "Add files, apps, and more (@)" msgstr "新增檔案、應用等(@)" +msgid "Add folder" +msgstr "新增資料夾" + #. placeholder {0}: issue.issueNumber msgid "Add issue #{0} to workspace" msgstr "將議題#{0}新增到工作區" @@ -1535,6 +1550,12 @@ msgstr "給風險最高又沒測試的程式碼補上測試…" msgid "Add tests where they're missing" msgstr "給缺測試的地方補測試" +msgid "Add the repositories this project works across as source folders. Every workspace checks out all of them." +msgstr "將此專案需要用到的儲存庫新增為來源資料夾。每個工作區都會簽出全部儲存庫。" + +msgid "Add the repositories this project works across. The first one is the primary." +msgstr "新增此專案需要用到的儲存庫。第一個是主要資料夾。" + msgid "Add to Dictionary" msgstr "新增到詞典" @@ -1547,6 +1568,10 @@ msgstr "新增到工作區" msgid "Add your own terminal agent to this device." msgstr "向此裝置新增你自己的終端代理程式。" +#. placeholder {0}: result.folder +msgid "Added {0} to this project" +msgstr "已將 {0} 新增至此專案" + msgid "Added {added} files to {where}" msgstr "已向{where}新增{added}個檔案" @@ -1973,6 +1998,9 @@ msgstr "匿名組裝工" msgid "another device" msgstr "另一臺裝置" +msgid "Another folder in this project already has that name." +msgstr "此專案中已有相同名稱的資料夾。" + msgid "Answered" msgstr "已回答" @@ -2964,6 +2992,9 @@ msgstr "思維鏈" msgid "Change avatar" msgstr "更換頭像" +msgid "Change folder" +msgstr "變更資料夾" + msgid "Change location" msgstr "更改位置" @@ -2998,6 +3029,9 @@ msgstr "更改worktree位置" msgid "Change-failure rate at or below the human-era baseline for two consecutive quarters." msgstr "變更失敗率連續兩個季度不高於人類時代的基線。" +msgid "Changed files" +msgstr "已變更的檔案" + msgid "Changelog" msgstr "更新日誌" @@ -3334,6 +3368,9 @@ msgstr "克隆儲存庫,或匯入主機上已有的資料夾。" msgid "Clone, push, and create PRs." msgstr "複製、推送並建立 PR。" +msgid "Cloned into each new workspace" +msgstr "複製到每個新工作區" + #. placeholder {0}: result.repoPath msgid "Cloned to {0}" msgstr "已克隆到{0}" @@ -4244,6 +4281,10 @@ msgstr "無法建立專案" msgid "Could not create project. Please try a different name or check the logs." msgstr "無法建立專案。請換一個名稱,或檢視日誌。" +#. placeholder {0}: errorMessage(result.error) +msgid "Could not create the project: {0}" +msgstr "無法建立專案:{0}" + msgid "Could not delete account" msgstr "無法刪除帳戶" @@ -4638,6 +4679,12 @@ msgstr "建立PR" msgid "Create PR failed: {0}" msgstr "建立PR失敗:{0}" +msgid "Create project" +msgstr "建立專案" + +msgid "Create project with multiple folders" +msgstr "建立含多個資料夾的專案" + msgid "Create pull request" msgstr "建立拉取請求" @@ -4757,6 +4804,9 @@ msgstr "正在建立worktree" msgid "Creating..." msgstr "正在建立..." +msgid "Creating…" +msgstr "正在建立…" + msgid "Creative Manager, Valid" msgstr "創意經理,Valid" @@ -4987,6 +5037,9 @@ msgstr "刪除「{title}」?" msgid "Delete {0}?" msgstr "刪除{0}?" +msgid "Delete {groupName}?" +msgstr "刪除 {groupName}?" + msgid "Delete {name}?" msgstr "刪除{name}?" @@ -5625,6 +5678,9 @@ msgstr "每個代理程式都跑在自己隔離的Git worktree裡。沒有合併 msgid "Each attachment is at most {0} MB." msgstr "每個附件最大為{0} MB。" +msgid "Each folder runs its own setup, teardown and run commands, in this order." +msgstr "每個資料夾會依此順序執行各自的初始化、清理與執行命令。" + msgid "Each in its own new tab" msgstr "每條各佔一個新分頁" @@ -6787,6 +6843,9 @@ msgstr "資料夾" msgid "Folder links" msgstr "資料夾連結" +msgid "Folder name" +msgstr "資料夾名稱" + msgid "Folder view" msgstr "資料夾檢視" @@ -7954,6 +8013,9 @@ msgstr "條目" msgid "Its own branch and copy of the files" msgstr "獨立的分支和檔案副本" +msgid "Its source folders stay on this device as projects of their own." +msgstr "它的來源資料夾會各自成為獨立的專案,保留在此裝置上。" + msgid "Its workspaces and terminals are unavailable until it restarts — use the Superset tray menu > Host Service > Restart." msgstr "在它重啟之前,其工作區和終端都不可用,請使用Superset托盤選單 > 主機服務 > 重啟。" @@ -8474,6 +8536,9 @@ msgstr "正在載入差異…" msgid "Loading files..." msgstr "正在載入檔案..." +msgid "Loading folders…" +msgstr "正在載入資料夾…" + msgid "Loading fonts..." msgstr "正在載入字型..." @@ -8630,6 +8695,9 @@ msgstr "設為預設" msgid "Make default — launch new terminals and agents on this account." msgstr "設為預設——新終端和代理程式將使用此帳戶啟動。" +msgid "Make primary" +msgstr "設為主要" + msgid "Make sure the device is awake, online, and running Superset." msgstr "請確認裝置未進入睡眠模式、已連上網路且正在執行 Superset。" @@ -9083,6 +9151,9 @@ msgstr "我的代理程式" msgid "My Tasks" msgstr "我的任務" +msgid "my-project" +msgstr "my-project" + msgid "Mystery Machinist" msgstr "神秘機械師" @@ -9310,6 +9381,12 @@ msgstr "新增工作區建立者" msgid "New workspace in {name}" msgstr "在 {name} 中新建工作區" +msgid "New workspaces will no longer check its folders out together. Existing workspaces keep their checkouts, and no repository is deleted from disk." +msgstr "新工作區將不再把它的資料夾一起簽出。現有工作區會保留各自的簽出,磁碟上的儲存庫不會被刪除。" + +msgid "New workspaces will stop checking this repository out. Existing workspaces keep it, and nothing is deleted from disk." +msgstr "新工作區將不再簽出此儲存庫。現有工作區會保留它,磁碟上的內容不會被刪除。" + msgid "New-user activation" msgstr "新使用者啟用" @@ -11095,6 +11172,9 @@ msgstr "請選擇父目錄" msgid "Pick a project location" msgstr "請選擇專案位置" +msgid "Pick a repository on {hostName} to add to this project." +msgstr "在 {hostName} 上選擇要新增至此專案的儲存庫。" + msgid "Pick a sound or add your own. Custom audio supports .mp3, .wav, and .ogg." msgstr "選擇一個聲音或新增自己的。自訂音訊支援.mp3、.wav和.ogg。" @@ -11392,6 +11472,10 @@ msgstr "價格取決於你的團隊規模和使用場景。聯絡我們,我們 msgid "Primary" msgstr "主色" +msgctxt "badge on a project's primary source folder" +msgid "Primary" +msgstr "主要" + msgid "Primary Key" msgstr "主鍵" @@ -12011,16 +12095,24 @@ msgstr "遠端工作區,裝置離線" msgid "Remove" msgstr "移除" +#. placeholder {0}: folder.name #. placeholder {0}: member.name msgid "Remove {0}" msgstr "移除{0}" +#. placeholder {0}: folder?.folder +msgid "Remove {0} from this project?" +msgstr "要將 {0} 從此專案中移除嗎?" + msgid "Remove {label}?" msgstr "移除{label}?" msgid "Remove attachment" msgstr "移除附件" +msgid "Remove folder" +msgstr "移除資料夾" + msgid "Remove from Queue" msgstr "移出佇列" @@ -12112,6 +12204,9 @@ msgstr "重新命名失敗" msgid "Rename failed: {0}" msgstr "重新命名失敗:{0}" +msgid "Rename folder" +msgstr "重新命名資料夾" + msgid "Rename group" msgstr "重新命名群組" @@ -12219,6 +12314,9 @@ msgstr "儲存庫" msgid "Repositories accessible through the GitHub App installation." msgstr "通過GitHub App安裝可存取的儲存庫。" +msgid "Repositories checked out into every workspace for this project. The primary is the one single-repository tools use." +msgstr "為此專案的每個工作區簽出的儲存庫。主要資料夾是單一儲存庫工具所使用的那一個。" + msgid "Repositories refreshed" msgstr "儲存庫已重新整理" @@ -13144,6 +13242,12 @@ msgstr "選擇一個專案來檢視拉取請求。" msgid "Select a pull request to preview it here." msgstr "選擇一個拉取請求,在這裡預覽。" +msgid "Select a repository to add" +msgstr "選擇要新增的儲存庫" + +msgid "Select a source folder" +msgstr "選擇來源資料夾" + msgid "Select a team" msgstr "選擇團隊" @@ -13993,6 +14097,13 @@ msgstr "來源" msgid "Source Available" msgstr "原始碼可見" +#. placeholder {0}: parentProject.name +msgid "Source folder of {0}" +msgstr "{0} 的來源資料夾" + +msgid "Source folders" +msgstr "來源資料夾" + msgid "Source path (not an import)" msgstr "原始碼路徑(不是匯入路徑)" @@ -14939,6 +15050,9 @@ msgstr "無法解析該端點。" msgid "That endpoint is not allowed." msgstr "不允許該端點。" +msgid "That folder is already in the list" +msgstr "該資料夾已在清單中" + msgid "That handle is taken." msgstr "該使用者名稱已被佔用。" @@ -15153,6 +15267,9 @@ msgstr "你要找的頁面不存在。" msgid "The people behind Superset." msgstr "Superset背後的團隊。" +msgid "The primary folder can't be removed. Make another folder primary first." +msgstr "無法移除主要資料夾。請先將另一個資料夾設為主要。" + msgid "The project's checkout as it is: files and branch are shared" msgstr "按原樣使用專案的簽出: 檔案和分支是共用的" @@ -15402,6 +15519,9 @@ msgstr "這比平時耗時更久。會話可能已經就緒 — 重新載入即 msgid "This is taking longer than usual. The workspace may already be ready — reloading can pick it up." msgstr "這比平時耗時更久。工作區可能已經就緒 — 重新載入即可接上。" +msgid "This is the directory the repository is checked out into inside each workspace." +msgstr "這是儲存庫在每個工作區中簽出的目錄。" + msgid "This issue link is invalid." msgstr "此議題連結無效。" @@ -15463,6 +15583,9 @@ msgstr "此埠仍在使用中" msgid "This profile is busy right now" msgstr "此個人檔案目前繁忙" +msgid "This project has no folders on {hostName} yet." +msgstr "此專案在 {hostName} 上尚未有任何資料夾。" + msgid "This project is no longer available on your devices." msgstr "這個專案在你的裝置上已不可用。" @@ -16290,6 +16413,9 @@ msgstr "用於新代理程式" msgid "Use global default" msgstr "使用全域性預設值" +msgid "Use letters, digits, dots, dashes or underscores, starting with a letter or digit." +msgstr "請使用字母、數字、點、破折號或底線,並以字母或數字開頭。" + msgid "Use Superset on mobile" msgstr "在手機上使用 Superset" @@ -16779,6 +16905,9 @@ msgstr "認真的團隊都在這裡" msgid "Where sessions begin: the home page, a compare page, the docs, a post, or a changelog entry. Content that ranks shows up here before it shows up in signups." msgstr "工作階段的起點:首頁、比較頁、文件、文章或更新日誌。有排名的內容會先出現在這裡,然後才反映在註冊上。" +msgid "Where the container holding this project's checkouts is created." +msgstr "建立存放此專案簽出內容之容器的位置。" + msgid "Where the industry is, honestly" msgstr "行業實際走到了哪裡"