Repository navigation
feat(desktop,host-service): add right-click import of untracked worktrees #6097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { Button } from "@superset/ui/button"; | ||
| import { Checkbox } from "@superset/ui/checkbox"; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogDescription, | ||
| DialogFooter, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| } from "@superset/ui/dialog"; | ||
| import { Label } from "@superset/ui/label"; | ||
| import { useState } from "react"; | ||
| import { LuGitBranch } from "react-icons/lu"; | ||
|
|
||
| export interface ImportableWorktree { | ||
| branch: string; | ||
| path: string; | ||
| } | ||
|
|
||
| interface ImportWorktreesDialogProps { | ||
| open: boolean; | ||
| worktrees: ImportableWorktree[]; | ||
| isImporting: boolean; | ||
| onOpenChange: (open: boolean) => void; | ||
| onConfirm: (options: { runSetup: boolean }) => void; | ||
| } | ||
|
|
||
| export function ImportWorktreesDialog({ | ||
| open, | ||
| worktrees, | ||
| isImporting, | ||
| onOpenChange, | ||
| onConfirm, | ||
| }: ImportWorktreesDialogProps) { | ||
| const [runSetup, setRunSetup] = useState(false); | ||
| const count = worktrees.length; | ||
|
|
||
| return ( | ||
| <Dialog | ||
| open={open} | ||
| onOpenChange={isImporting ? undefined : onOpenChange} | ||
| modal | ||
| > | ||
| <DialogContent className="max-w-md"> | ||
| <DialogHeader> | ||
| <DialogTitle>Import untracked worktrees</DialogTitle> | ||
| <DialogDescription> | ||
| {count === 1 | ||
| ? "1 worktree in this repository has no workspace. It will be imported as a workspace." | ||
| : `${count} worktrees in this repository have no workspace. They will be imported as workspaces.`} | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
| <ul className="-mx-2.5 max-h-64 overflow-x-hidden overflow-y-auto overscroll-contain"> | ||
| {worktrees.map((worktree) => ( | ||
| <li | ||
| key={worktree.path} | ||
| className="grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] items-center gap-x-3 rounded-md px-2.5 py-2" | ||
| > | ||
| <LuGitBranch | ||
| className="size-4 shrink-0 text-muted-foreground" | ||
| strokeWidth={2} | ||
| /> | ||
| <div className="flex min-w-0 flex-col"> | ||
| <span | ||
| className="truncate text-[13px] font-medium leading-4 text-foreground" | ||
| title={worktree.branch} | ||
| > | ||
| {worktree.branch} | ||
| </span> | ||
| <span | ||
| className="mt-0.5 truncate font-mono text-[11px] leading-4 text-muted-foreground" | ||
| title={worktree.path} | ||
| > | ||
| {worktree.path} | ||
| </span> | ||
| </div> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| <div className="flex items-center gap-2"> | ||
| <Checkbox | ||
| id="import-worktrees-run-setup" | ||
| checked={runSetup} | ||
| onCheckedChange={(checked) => setRunSetup(checked === true)} | ||
| disabled={isImporting} | ||
| /> | ||
| <Label | ||
| htmlFor="import-worktrees-run-setup" | ||
| className="cursor-pointer text-[13px] font-normal text-muted-foreground" | ||
| > | ||
| Run setup script in each imported workspace | ||
| </Label> | ||
| </div> | ||
| <DialogFooter> | ||
| <Button | ||
| variant="outline" | ||
| onClick={() => onOpenChange(false)} | ||
| disabled={isImporting} | ||
| > | ||
| Cancel | ||
| </Button> | ||
| <Button | ||
| onClick={() => onConfirm({ runSetup })} | ||
| disabled={isImporting} | ||
| > | ||
| {isImporting | ||
| ? "Importing…" | ||
| : count === 1 | ||
| ? "Import 1 worktree" | ||
| : `Import ${count} worktrees`} | ||
| </Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export type { ImportableWorktree } from "./ImportWorktreesDialog"; | ||
| export { ImportWorktreesDialog } from "./ImportWorktreesDialog"; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,7 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { alert } from "@superset/ui/atoms/Alert"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { toast } from "@superset/ui/sonner"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useNavigate } from "@tanstack/react-router"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useMemo, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useMemo, useRef, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -10,7 +10,9 @@ import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useWorkspaceCreates } from "renderer/stores/workspace-creates"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { DashboardSidebarProject } from "../../../../types"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { ImportableWorktree } from "../../components/ImportWorktreesDialog"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface UseDashboardSidebarProjectSectionActionsOptions { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| project: DashboardSidebarProject; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -36,6 +38,13 @@ export function useDashboardSidebarProjectSectionActions({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // undefined (not null) when no host serves it — null would resolve to | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the local host and rename the wrong replica. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const servingHostUrl = useHostUrl(servingHostId ?? undefined); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { submit } = useWorkspaceCreates(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const importingWorktreesRef = useRef(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Non-null while the import confirmation dialog is open. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [importableWorktrees, setImportableWorktrees] = useState< | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ImportableWorktree[] | null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| >(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [isImportingWorktrees, setIsImportingWorktrees] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { requestSectionRename } = useDashboardSidebarSectionRename(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| createSection, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -124,6 +133,94 @@ export function useDashboardSidebarProjectSectionActions({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openModal(project.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Menu action: list the worktrees git knows about that have no workspace | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // row yet and open the confirmation dialog. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleImportWorktrees = async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (importingWorktreesRef.current) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!servingHostUrl) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Project's host is unreachable — cannot import worktrees right now", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { worktrees } = await getHostServiceClientByUrl( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| servingHostUrl, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).workspaceCreation.listProjectWorktrees.query({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| projectId: project.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `=== false` also skips hosts too old to report tracked-ness. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const untracked = worktrees.filter( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (worktree) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| worktree.hasWorkspace === false && worktree.isMainWorktree === false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (untracked.length === 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.info("All of this project's worktrees are already tracked"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setImportableWorktrees(untracked); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `Failed to list worktrees: ${error instanceof Error ? error.message : String(error)}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Dialog confirm: adopt each worktree via the `worktreePath` branch of | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `workspaces.create` (no `git worktree add`; setup only when opted in). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const confirmImportWorktrees = async ({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runSetup, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runSetup: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const untracked = importableWorktrees; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (importingWorktreesRef.current || !untracked) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!servingHostId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Project's host is unreachable — cannot import worktrees right now", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| importingWorktreesRef.current = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setIsImportingWorktrees(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const outcomes = await Promise.all( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| untracked.map( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (worktree) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submit({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hostId: servingHostId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| snapshot: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: crypto.randomUUID(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| projectId: project.id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| branch: worktree.branch, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| worktreePath: worktree.path, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runSetup, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }).completed, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const errors = outcomes.flatMap((outcome) => | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| outcome.ok ? [] : [outcome.error], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const imported = outcomes.length - errors.length; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (errors.length > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `Imported ${imported} of ${untracked.length} worktrees: ${errors[0]}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| toast.success( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| imported === 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? "Imported 1 worktree as a workspace" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : `Imported ${imported} worktrees as workspaces`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+202
to
+216
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Surface more than the first error on partial import failure. When several worktrees fail for different reasons, Include the failure count or join distinct error messages instead of showing only the first one. 💬 Proposed fix to surface more failure detail if (errors.length > 0) {
+ const uniqueErrors = [...new Set(errors)];
toast.error(
- `Imported ${imported} of ${untracked.length} worktrees: ${errors[0]}`,
+ `Imported ${imported} of ${untracked.length} worktrees: ${uniqueErrors.join("; ")}`,
);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setImportableWorktrees(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| importingWorktreesRef.current = false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setIsImportingWorktrees(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleNewSection = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sectionId = createSection(project.id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| requestSectionRename(sectionId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -134,15 +231,20 @@ export function useDashboardSidebarProjectSectionActions({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cancelRename, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| confirmImportWorktrees, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| confirmRemoveFromSidebar, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| deleteSection, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleImportWorktrees, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleNewSection, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleNewWorkspace, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleOpenInFinder, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleOpenSettings, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| importableWorktrees, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isImportingWorktrees, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isRenaming, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| renameSection, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| renameValue, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setImportableWorktrees, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setRenameValue, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| startRename, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| submitRename, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: On hosts that predate the new
hasWorkspace/isMainWorktreefields,worktree.hasWorkspace === falseisfalsebecause the field isundefined, so the list is treated as fully tracked and users get the 'All of this project's worktrees are already tracked' toast. That message is misleading because the real cause is host-version incompatibility, not zero untracked worktrees. Consider detecting the old-host case (e.g., no worktree carries the new fields) and showing a dedicated 'update your host' message instead of a false 'all tracked' success toast.Prompt for AI agents