diff --git a/apps/array/src/main/services/context-menu/schemas.ts b/apps/array/src/main/services/context-menu/schemas.ts index 5ba7573c..f0dafca2 100644 --- a/apps/array/src/main/services/context-menu/schemas.ts +++ b/apps/array/src/main/services/context-menu/schemas.ts @@ -77,6 +77,18 @@ export type TabAction = z.infer; export type FileAction = z.infer; export type SplitDirection = z.infer; +export const confirmDeleteTaskInput = z.object({ + taskTitle: z.string(), + hasWorktree: z.boolean(), +}); + +export const confirmDeleteTaskOutput = z.object({ + confirmed: z.boolean(), +}); + +export type ConfirmDeleteTaskInput = z.infer; +export type ConfirmDeleteTaskResult = z.infer; + export type TaskContextMenuResult = z.infer; export type FolderContextMenuResult = z.infer; export type TabContextMenuResult = z.infer; diff --git a/apps/array/src/main/services/context-menu/service.ts b/apps/array/src/main/services/context-menu/service.ts index 2f84a290..b5a9a107 100644 --- a/apps/array/src/main/services/context-menu/service.ts +++ b/apps/array/src/main/services/context-menu/service.ts @@ -10,6 +10,8 @@ import { MAIN_TOKENS } from "../../di/tokens.js"; import { getMainWindow } from "../../trpc/context.js"; import type { ExternalAppsService } from "../external-apps/service.js"; import type { + ConfirmDeleteTaskInput, + ConfirmDeleteTaskResult, FileAction, FileContextMenuInput, FileContextMenuResult, @@ -49,30 +51,31 @@ export class ContextMenuService { return { apps, lastUsedAppId: lastUsed.lastUsedApp }; } + async confirmDeleteTask( + input: ConfirmDeleteTaskInput, + ): Promise { + const confirmed = await this.confirm({ + title: "Delete Task", + message: `Delete "${input.taskTitle}"?`, + detail: input.hasWorktree + ? "This will permanently delete the task and its associated worktree." + : "This will permanently delete the task.", + confirmLabel: "Delete", + }); + return { confirmed }; + } + async showTaskContextMenu( input: TaskContextMenuInput, ): Promise { - const { taskTitle, worktreePath } = input; + const { worktreePath } = input; const { apps, lastUsedAppId } = await this.getExternalAppsData(); return this.showMenu([ this.item("Rename", { type: "rename" }), this.item("Duplicate", { type: "duplicate" }), this.separator(), - this.item( - "Delete", - { type: "delete" }, - { - confirm: { - title: "Delete Task", - message: `Delete "${taskTitle}"?`, - detail: worktreePath - ? "This will permanently delete the task and its associated worktree." - : "This will permanently delete the task.", - confirmLabel: "Delete", - }, - }, - ), + this.item("Delete", { type: "delete" }), ...(worktreePath ? [ this.separator(), diff --git a/apps/array/src/main/services/folders/schemas.ts b/apps/array/src/main/services/folders/schemas.ts index 9088e644..e0ac67cf 100644 --- a/apps/array/src/main/services/folders/schemas.ts +++ b/apps/array/src/main/services/folders/schemas.ts @@ -8,13 +8,17 @@ export const registeredFolderSchema = z.object({ createdAt: z.string(), }); -export const getFoldersOutput = z.array(registeredFolderSchema); +export const registeredFolderWithExistsSchema = registeredFolderSchema.extend({ + exists: z.boolean(), +}); + +export const getFoldersOutput = z.array(registeredFolderWithExistsSchema); export const addFolderInput = z.object({ folderPath: z.string().min(2, "Folder path must be a valid directory path"), }); -export const addFolderOutput = registeredFolderSchema; +export const addFolderOutput = registeredFolderWithExistsSchema; export const removeFolderInput = z.object({ folderId: z.string(), diff --git a/apps/array/src/main/services/folders/service.ts b/apps/array/src/main/services/folders/service.ts index b3d5142a..473bdabc 100644 --- a/apps/array/src/main/services/folders/service.ts +++ b/apps/array/src/main/services/folders/service.ts @@ -1,4 +1,5 @@ import { exec } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { WorktreeManager } from "@posthog/agent"; @@ -20,13 +21,21 @@ const log = logger.scope("folders-service"); @injectable() export class FoldersService { - async getFolders(): Promise { + async getFolders(): Promise<(RegisteredFolder & { exists: boolean })[]> { const folders = foldersStore.get("folders", []); // Filter out any folders with empty names (from invalid paths like "/") - return folders.filter((f) => f.name && f.path); + // Also add exists property to check if path is valid on disk + return folders + .filter((f) => f.name && f.path) + .map((f) => ({ + ...f, + exists: fs.existsSync(f.path), + })); } - async addFolder(folderPath: string): Promise { + async addFolder( + folderPath: string, + ): Promise { // Validate the path before proceeding const folderName = path.basename(folderPath); if (!folderPath || !folderName) { @@ -69,7 +78,7 @@ export class FoldersService { if (existing) { existing.lastAccessed = new Date().toISOString(); foldersStore.set("folders", folders); - return existing; + return { ...existing, exists: true }; } const newFolder: RegisteredFolder = { @@ -83,7 +92,7 @@ export class FoldersService { folders.push(newFolder); foldersStore.set("folders", folders); - return newFolder; + return { ...newFolder, exists: true }; } async removeFolder(folderId: string): Promise { diff --git a/apps/array/src/main/trpc/routers/context-menu.ts b/apps/array/src/main/trpc/routers/context-menu.ts index b4bbb15c..fb0d5415 100644 --- a/apps/array/src/main/trpc/routers/context-menu.ts +++ b/apps/array/src/main/trpc/routers/context-menu.ts @@ -1,6 +1,8 @@ import { container } from "../../di/container.js"; import { MAIN_TOKENS } from "../../di/tokens.js"; import { + confirmDeleteTaskInput, + confirmDeleteTaskOutput, fileContextMenuInput, fileContextMenuOutput, folderContextMenuInput, @@ -18,6 +20,11 @@ const getService = () => container.get(MAIN_TOKENS.ContextMenuService); export const contextMenuRouter = router({ + confirmDeleteTask: publicProcedure + .input(confirmDeleteTaskInput) + .output(confirmDeleteTaskOutput) + .mutation(({ input }) => getService().confirmDeleteTask(input)), + showTaskContextMenu: publicProcedure .input(taskContextMenuInput) .output(taskContextMenuOutput) diff --git a/apps/array/src/renderer/components/GlobalEventHandlers.tsx b/apps/array/src/renderer/components/GlobalEventHandlers.tsx index 16409111..8038aec0 100644 --- a/apps/array/src/renderer/components/GlobalEventHandlers.tsx +++ b/apps/array/src/renderer/components/GlobalEventHandlers.tsx @@ -1,8 +1,10 @@ import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; import { useRightSidebarStore } from "@features/right-sidebar"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; +import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore"; import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; import { clearApplicationStorage } from "@renderer/lib/clearStorage"; +import { useRegisteredFoldersStore } from "@renderer/stores/registeredFoldersStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useCallback, useEffect } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -23,8 +25,14 @@ export function GlobalEventHandlers({ const navigateToTaskInput = useNavigationStore( (state) => state.navigateToTaskInput, ); + const navigateToFolderSettings = useNavigationStore( + (state) => state.navigateToFolderSettings, + ); + const view = useNavigationStore((state) => state.view); const goBack = useNavigationStore((state) => state.goBack); const goForward = useNavigationStore((state) => state.goForward); + const folders = useRegisteredFoldersStore((state) => state.folders); + const workspaces = useWorkspaceStore.use.workspaces(); const clearAllLayouts = usePanelLayoutStore((state) => state.clearAllLayouts); const toggleLeftSidebar = useSidebarStore((state) => state.toggle); const toggleRightSidebar = useRightSidebarStore((state) => state.toggle); @@ -101,6 +109,28 @@ export function GlobalEventHandlers({ }; }, [goBack, goForward]); + // Reload folders when window regains focus to detect moved/deleted folders + useEffect(() => { + const handleFocus = () => { + useRegisteredFoldersStore.getState().loadFolders(); + }; + window.addEventListener("focus", handleFocus); + return () => window.removeEventListener("focus", handleFocus); + }, []); + + // Check if current task's folder became invalid (e.g., moved while app was open) + useEffect(() => { + if (view.type !== "task-detail" || !view.data) return; + + const workspace = workspaces[view.data.id]; + if (!workspace?.folderId) return; + + const folder = folders.find((f) => f.id === workspace.folderId); + if (folder && folder.exists === false) { + navigateToFolderSettings(folder.id); + } + }, [view, folders, workspaces, navigateToFolderSettings]); + trpcReact.ui.onOpenSettings.useSubscription(undefined, { onData: handleOpenSettings, }); diff --git a/apps/array/src/renderer/components/MainLayout.tsx b/apps/array/src/renderer/components/MainLayout.tsx index acbcc507..11a8aad4 100644 --- a/apps/array/src/renderer/components/MainLayout.tsx +++ b/apps/array/src/renderer/components/MainLayout.tsx @@ -4,6 +4,7 @@ import { StatusBar } from "@components/StatusBar"; import { UpdatePrompt } from "@components/UpdatePrompt"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { RightSidebar, RightSidebarContent } from "@features/right-sidebar"; +import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; import { SettingsView } from "@features/settings/components/SettingsView"; import { MainSidebar } from "@features/sidebar/components/MainSidebar"; import { TaskDetail } from "@features/task-detail/components/TaskDetail"; @@ -55,6 +56,8 @@ export function MainLayout() { )} {view.type === "settings" && } + + {view.type === "folder-settings" && } {view.type === "task-detail" && view.data && ( diff --git a/apps/array/src/renderer/features/message-editor/components/MessageEditor.tsx b/apps/array/src/renderer/features/message-editor/components/MessageEditor.tsx index 1f2c50bd..40b2f10b 100644 --- a/apps/array/src/renderer/features/message-editor/components/MessageEditor.tsx +++ b/apps/array/src/renderer/features/message-editor/components/MessageEditor.tsx @@ -113,7 +113,7 @@ export const MessageEditor = forwardRef( onClick={handleContainerClick} style={{ cursor: "text" }} > -
+
diff --git a/apps/array/src/renderer/features/settings/components/FolderSettingsView.tsx b/apps/array/src/renderer/features/settings/components/FolderSettingsView.tsx new file mode 100644 index 00000000..f664f559 --- /dev/null +++ b/apps/array/src/renderer/features/settings/components/FolderSettingsView.tsx @@ -0,0 +1,199 @@ +import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import { Warning } from "@phosphor-icons/react"; +import { + Box, + Button, + Callout, + Card, + Code, + Flex, + Heading, + Text, +} from "@radix-ui/themes"; +import { logger } from "@renderer/lib/logger"; +import { useNavigationStore } from "@renderer/stores/navigationStore"; +import { useRegisteredFoldersStore } from "@renderer/stores/registeredFoldersStore"; +import { useState } from "react"; + +const log = logger.scope("folder-settings"); + +export function FolderSettingsView() { + useSetHeaderContent(null); + + const { view, navigateToTaskInput } = useNavigationStore(); + const { folders, removeFolder } = useRegisteredFoldersStore(); + + const folderId = view.type === "folder-settings" ? view.folderId : undefined; + const folder = folders.find((f) => f.id === folderId); + + const [error, setError] = useState(null); + + const handleRemoveFolder = async () => { + if (!folderId) return; + try { + await removeFolder(folderId); + navigateToTaskInput(); + } catch (err) { + log.error("Failed to remove folder:", err); + setError(err instanceof Error ? err.message : "Failed to remove folder"); + } + }; + + if (!folder) { + return ( + + + + + + + Repository not found + + + + ); + } + + // When folder doesn't exist, show message to restore or remove + if (!folder.exists) { + return ( + + + + + Repository Not Found + + {folder.name} + + + + + + + + + + + The repository folder could not be found + + + The folder at {folder.path} no longer exists or + has been moved. + + + + + + {error && ( + + {error} + + )} + + + + + + Option 1: Restore the folder + + + Move or restore the repository folder back to its original + location: + + {folder.path} + + + + + + + + + Option 2: Remove the repository + + + This will remove the repository from Array, including all + associated tasks and their workspaces. This action cannot be + undone. + + + + + + + + + ); + } + + // Normal settings view when folder exists + return ( + + + + + Repository Settings + + Manage settings for {folder.name} + + + + {error && ( + + {error} + + )} + + + Location + + + + Root path + + {folder.path} + + + + + + + + Danger zone + + + + + Remove repository + + + This will remove the repository from Array, including all + associated tasks and their workspaces. This action cannot be + undone. + + + + + + + + + + ); +} diff --git a/apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx b/apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx index a8d5949b..699ad076 100644 --- a/apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx +++ b/apps/array/src/renderer/features/sidebar/components/SidebarItem.tsx @@ -27,7 +27,7 @@ export function SidebarItem({ return ( - + + {onSettingsClick && ( + + )} + {isExpanded ? ( + + ) : ( + + )} + + + +
{children} diff --git a/apps/array/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/array/src/renderer/features/sidebar/components/items/TaskItem.tsx index b589dbb5..810b609d 100644 --- a/apps/array/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/array/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -1,9 +1,16 @@ import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; -import { Cloud, GitBranch as GitBranchIcon } from "@phosphor-icons/react"; +import { + Cloud, + GitBranch as GitBranchIcon, + PushPin, + PushPinSlash, + Trash, +} from "@phosphor-icons/react"; import { trpcVanilla } from "@renderer/trpc"; import { formatRelativeTime } from "@renderer/utils/time"; import type { WorkspaceMode } from "@shared/types"; import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; import { SidebarItem } from "../SidebarItem"; function useCurrentBranch(repoPath?: string, worktreeName?: string) { @@ -31,8 +38,50 @@ interface TaskItemProps { lastActivityAt?: number; isGenerating?: boolean; isUnread?: boolean; + isPinned?: boolean; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void; + onDelete?: () => void; + onTogglePin?: () => void; +} + +interface TaskHoverToolbarProps { + isPinned: boolean; + onDelete: () => void; + onTogglePin: () => void; +} + +function TaskHoverToolbar({ + isPinned, + onDelete, + onTogglePin, +}: TaskHoverToolbarProps) { + return ( + + + + + ); } interface DiffStatsDisplayProps { @@ -89,8 +138,11 @@ export function TaskItem({ lastActivityAt, isGenerating, isUnread, + isPinned = false, onClick, onContextMenu, + onDelete, + onTogglePin, }: TaskItemProps) { const { data: currentBranch } = useCurrentBranch(worktreePath, worktreeName); @@ -126,10 +178,32 @@ export function TaskItem({ + ) : isPinned ? ( + ) : ( ); + const endContent = useMemo( + () => ( + + {!isCloudTask && worktreePath && ( + + + + )} + {onDelete && onTogglePin && ( + + )} + + ), + [isCloudTask, worktreePath, onDelete, onTogglePin, isPinned], + ); + return ( - ) : undefined - } + endContent={endContent} /> ); } diff --git a/apps/array/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/array/src/renderer/features/sidebar/hooks/useSidebarData.ts index 9605f04d..7144b3f3 100644 --- a/apps/array/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/array/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -15,6 +15,7 @@ import { getTaskRepository, parseRepository, } from "@/renderer/utils/repository"; +import { usePinnedTasksStore } from "../stores/pinnedTasksStore"; import { useSidebarStore } from "../stores/sidebarStore"; import { useTaskViewedStore } from "../stores/taskViewedStore"; @@ -42,6 +43,7 @@ export interface TaskData { lastActivityAt?: number; isGenerating?: boolean; isUnread?: boolean; + isPinned?: boolean; } export interface SidebarData { @@ -57,7 +59,7 @@ export interface SidebarData { } interface ViewState { - type: "task-detail" | "task-input" | "settings"; + type: "task-detail" | "task-input" | "settings" | "folder-settings"; data?: Task; } @@ -181,6 +183,7 @@ export function useSidebarData({ const localActivityAt = useTaskViewedStore((state) => state.lastActivityAt); const folderOrder = useSidebarStore((state) => state.folderOrder); const syncFolderOrder = useSidebarStore((state) => state.syncFolderOrder); + const pinnedTaskIds = usePinnedTasksStore((state) => state.pinnedTaskIds); const userName = currentUser?.first_name || currentUser?.email || "Account"; const isHomeActive = activeView.type === "task-input"; @@ -221,36 +224,48 @@ export function useSidebarData({ const lastActivityAt = localActivity ? Math.max(apiUpdatedAt, localActivity) : apiUpdatedAt; + const isPinned = pinnedTaskIds.has(task.id); return { task, lastActivityAt, isGenerating: session?.isPromptPending ?? false, + isPinned, }; }); - tasksWithActivity.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + // Sort by pinned first, then by most recent activity + tasksWithActivity.sort((a, b) => { + // Pinned tasks come first + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + // Then sort by most recent activity + return b.lastActivityAt - a.lastActivityAt; + }); return { id: folder.id, name: folder.name, path: folder.path, - tasks: tasksWithActivity.map(({ task, lastActivityAt, isGenerating }) => { - const taskLastViewedAt = lastViewedAt[task.id]; - const isCurrentlyViewing = activeTaskId === task.id; - // Only show unread if: user has viewed it before AND there's new activity since - const isUnread = - !isCurrentlyViewing && - taskLastViewedAt !== undefined && - lastActivityAt > taskLastViewedAt; - - return { - id: task.id, - title: task.title, - lastActivityAt, - isGenerating, - isUnread, - }; - }), + tasks: tasksWithActivity.map( + ({ task, lastActivityAt, isGenerating, isPinned }) => { + const taskLastViewedAt = lastViewedAt[task.id]; + const isCurrentlyViewing = activeTaskId === task.id; + // Only show unread if: user has viewed it before AND there's new activity since + const isUnread = + !isCurrentlyViewing && + taskLastViewedAt !== undefined && + lastActivityAt > taskLastViewedAt; + + return { + id: task.id, + title: task.title, + lastActivityAt, + isGenerating, + isUnread, + isPinned, + }; + }, + ), }; }); diff --git a/apps/array/src/renderer/features/sidebar/stores/pinnedTasksStore.ts b/apps/array/src/renderer/features/sidebar/stores/pinnedTasksStore.ts new file mode 100644 index 00000000..2e0f41db --- /dev/null +++ b/apps/array/src/renderer/features/sidebar/stores/pinnedTasksStore.ts @@ -0,0 +1,46 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface PinnedTasksState { + pinnedTaskIds: Set; + togglePin: (taskId: string) => void; + unpin: (taskId: string) => void; + isPinned: (taskId: string) => boolean; +} + +export const usePinnedTasksStore = create()( + persist( + (set, get) => ({ + pinnedTaskIds: new Set(), + togglePin: (taskId: string) => + set((state) => { + const newPinnedTaskIds = new Set(state.pinnedTaskIds); + if (newPinnedTaskIds.has(taskId)) { + newPinnedTaskIds.delete(taskId); + } else { + newPinnedTaskIds.add(taskId); + } + return { pinnedTaskIds: newPinnedTaskIds }; + }), + unpin: (taskId: string) => + set((state) => { + const newPinnedTaskIds = new Set(state.pinnedTaskIds); + newPinnedTaskIds.delete(taskId); + return { pinnedTaskIds: newPinnedTaskIds }; + }), + isPinned: (taskId: string) => get().pinnedTaskIds.has(taskId), + }), + { + name: "pinned-tasks-storage", + partialize: (state) => ({ + pinnedTaskIds: Array.from(state.pinnedTaskIds), + }), + merge: (persisted, current) => ({ + ...current, + pinnedTaskIds: new Set( + (persisted as { pinnedTaskIds?: string[] })?.pinnedTaskIds ?? [], + ), + }), + }, + ), +); diff --git a/apps/array/src/renderer/features/tasks/hooks/useTasks.ts b/apps/array/src/renderer/features/tasks/hooks/useTasks.ts index 4ce1c6be..0a83403b 100644 --- a/apps/array/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/array/src/renderer/features/tasks/hooks/useTasks.ts @@ -1,11 +1,15 @@ +import { usePinnedTasksStore } from "@features/sidebar/stores/pinnedTasksStore"; import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { useMeQuery } from "@hooks/useMeQuery"; import { track } from "@renderer/lib/analytics"; import { logger } from "@renderer/lib/logger"; +import { useNavigationStore } from "@renderer/stores/navigationStore"; +import { trpcVanilla } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; import { useWorkspaceStore } from "@/renderer/features/workspace/stores/workspaceStore"; import { ANALYTICS_EVENTS } from "@/types/analytics"; @@ -109,10 +113,18 @@ export function useUpdateTask() { ); } +interface DeleteTaskOptions { + taskId: string; + taskTitle: string; + hasWorktree: boolean; +} + export function useDeleteTask() { const queryClient = useQueryClient(); + const { view, navigateToTaskInput } = useNavigationStore(); + const unpinTask = usePinnedTasksStore((state) => state.unpin); - return useAuthenticatedMutation( + const mutation = useAuthenticatedMutation( async (client, taskId: string) => { const workspaceStore = useWorkspaceStore.getState(); const workspace = workspaceStore.workspaces[taskId]; @@ -133,6 +145,35 @@ export function useDeleteTask() { }, }, ); + + const deleteWithConfirm = useCallback( + async ({ taskId, taskTitle, hasWorktree }: DeleteTaskOptions) => { + const result = await trpcVanilla.contextMenu.confirmDeleteTask.mutate({ + taskTitle, + hasWorktree, + }); + + if (!result.confirmed) { + return false; + } + + // Navigate away if viewing the deleted task + if (view.type === "task-detail" && view.data?.id === taskId) { + navigateToTaskInput(); + } + + // Clean up pinned state + unpinTask(taskId); + + // Delete the task + await mutation.mutateAsync(taskId); + + return true; + }, + [mutation, view, navigateToTaskInput, unpinTask], + ); + + return { ...mutation, deleteWithConfirm }; } export function useDuplicateTask() { diff --git a/apps/array/src/renderer/hooks/useTaskContextMenu.ts b/apps/array/src/renderer/hooks/useTaskContextMenu.ts index 35eb1641..4e984218 100644 --- a/apps/array/src/renderer/hooks/useTaskContextMenu.ts +++ b/apps/array/src/renderer/hooks/useTaskContextMenu.ts @@ -3,7 +3,6 @@ import { useDuplicateTask, } from "@features/tasks/hooks/useTasks"; import { logger } from "@renderer/lib/logger"; -import { useNavigationStore } from "@renderer/stores/navigationStore"; import { trpcVanilla } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { handleExternalAppAction } from "@utils/handleExternalAppAction"; @@ -15,8 +14,7 @@ export function useTaskContextMenu() { const [renameTask, setRenameTask] = useState(null); const [renameDialogOpen, setRenameDialogOpen] = useState(false); const duplicateTask = useDuplicateTask(); - const deleteTask = useDeleteTask(); - const { view, navigateToTaskInput } = useNavigationStore(); + const { deleteWithConfirm } = useDeleteTask(); const showContextMenu = useCallback( async (task: Task, event: React.MouseEvent, worktreePath?: string) => { @@ -42,10 +40,11 @@ export function useTaskContextMenu() { await duplicateTask.mutateAsync(task.id); break; case "delete": - if (view.type === "task-detail" && view.data?.id === task.id) { - navigateToTaskInput(); - } - await deleteTask.mutateAsync(task.id); + await deleteWithConfirm({ + taskId: task.id, + taskTitle: task.title, + hasWorktree: !!worktreePath, + }); break; case "external-app": if (worktreePath) { @@ -61,7 +60,7 @@ export function useTaskContextMenu() { log.error("Failed to show context menu", error); } }, - [duplicateTask, deleteTask, view, navigateToTaskInput], + [duplicateTask, deleteWithConfirm], ); return { diff --git a/apps/array/src/renderer/stores/navigationStore.ts b/apps/array/src/renderer/stores/navigationStore.ts index db4d39c6..b01b82bb 100644 --- a/apps/array/src/renderer/stores/navigationStore.ts +++ b/apps/array/src/renderer/stores/navigationStore.ts @@ -12,7 +12,7 @@ import { ANALYTICS_EVENTS } from "@/types/analytics"; const log = logger.scope("navigation-store"); -type ViewType = "task-detail" | "task-input" | "settings"; +type ViewType = "task-detail" | "task-input" | "settings" | "folder-settings"; interface ViewState { type: ViewType; @@ -28,6 +28,7 @@ interface NavigationStore { navigateToTask: (task: Task) => void; navigateToTaskInput: (folderId?: string) => void; navigateToSettings: () => void; + navigateToFolderSettings: (folderId: string) => void; toggleSettings: () => void; goBack: () => void; goForward: () => void; @@ -44,6 +45,9 @@ const isSameView = (view1: ViewState, view2: ViewState): boolean => { if (view1.type === "task-input" && view2.type === "task-input") { return view1.folderId === view2.folderId; } + if (view1.type === "folder-settings" && view2.type === "folder-settings") { + return view1.folderId === view2.folderId; + } return true; }; @@ -75,6 +79,34 @@ export const useNavigationStore = create()( }); const repoKey = getTaskRepository(task) ?? undefined; + + // Check if this task has an existing workspace with a folder + const existingWorkspace = + useWorkspaceStore.getState().workspaces[task.id]; + if (existingWorkspace?.folderId) { + const folder = useRegisteredFoldersStore + .getState() + .folders.find((f) => f.id === existingWorkspace.folderId); + + if (folder && folder.exists === false) { + log.info("Folder path is stale, redirecting to folder settings", { + folderId: folder.id, + path: folder.path, + }); + navigate({ type: "folder-settings", folderId: folder.id }); + return; + } + + if (folder) { + if (repoKey) { + useTaskDirectoryStore + .getState() + .setRepoDirectory(repoKey, folder.path); + } + return; + } + } + const directory = useTaskDirectoryStore .getState() .getTaskDirectory(task.id, repoKey); @@ -109,6 +141,10 @@ export const useNavigationStore = create()( track(ANALYTICS_EVENTS.SETTINGS_VIEWED); }, + navigateToFolderSettings: (folderId: string) => { + navigate({ type: "folder-settings", folderId }); + }, + toggleSettings: () => { const current = get().view; if (current.type === "settings") { diff --git a/apps/array/src/shared/types.ts b/apps/array/src/shared/types.ts index aeddc8d4..7a16abe5 100644 --- a/apps/array/src/shared/types.ts +++ b/apps/array/src/shared/types.ts @@ -4,6 +4,7 @@ export interface RegisteredFolder { name: string; lastAccessed: string; createdAt: string; + exists?: boolean; } export interface WorktreeInfo {