From a929ea9726bb62e19bf40d942cb3b44e980a5b00 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 12 Jun 2026 23:42:21 +0800 Subject: [PATCH] Show an error for missing project paths (#1490) * Fix missing project paths flashing in the sidebar * Log project lifecycle changes * Address project picker review feedback * Show project picker transport errors * Inject session filesystem for workspace tests --- packages/app/e2e/helpers/new-workspace.ts | 3 +- .../src/components/project-picker-modal.tsx | 142 ++++++++++++------ .../components/project-picker-options.test.ts | 80 ++++++++++ .../src/components/project-picker-options.ts | 48 ++++++ packages/app/src/hooks/open-project.ts | 32 +++- .../app/src/hooks/use-open-project.test.ts | 21 ++- packages/app/src/hooks/use-open-project.ts | 6 +- packages/app/src/i18n/resources/ar.ts | 5 + packages/app/src/i18n/resources/en.ts | 5 + packages/app/src/i18n/resources/es.ts | 5 + packages/app/src/i18n/resources/fr.ts | 5 + packages/app/src/i18n/resources/ru.ts | 5 + packages/app/src/i18n/resources/zh-CN.ts | 5 + packages/protocol/src/messages.ts | 2 + ...open-project-missing-directory.e2e.test.ts | 93 ++++++++++++ packages/server/src/server/session.ts | 99 +++++++++++- ...on.workspace-resolution-invariants.test.ts | 1 + .../src/server/session.workspaces.test.ts | 1 + .../workspace-reconciliation-service.test.ts | 96 ++++++++++++ .../workspace-reconciliation-service.ts | 22 ++- 20 files changed, 603 insertions(+), 73 deletions(-) create mode 100644 packages/app/src/components/project-picker-options.test.ts create mode 100644 packages/app/src/components/project-picker-options.ts create mode 100644 packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index a1530dc22..ab30d07c2 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -16,6 +16,7 @@ type NewWorkspaceDaemonClient = Pick< >; type OpenProjectPayload = Awaited>; +type WorkspacePayload = Pick; export interface OpenedProject { workspaceId: string; @@ -24,7 +25,7 @@ export interface OpenedProject { workspaceName: string; } -function requireWorkspace(payload: OpenProjectPayload) { +function requireWorkspace(payload: WorkspacePayload) { if (payload.error) { throw new Error(payload.error); } diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index a30ecb55e..5a5b0616d 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -8,27 +8,57 @@ import { View, type PressableStateCallbackType, } from "react-native"; -import { Folder } from "lucide-react-native"; +import { Folder, FolderPlus } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { shortenPath } from "@/utils/shorten-path"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useOpenProject } from "@/hooks/use-open-project"; -import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; +import type { OpenProjectFailure, OpenProjectResult } from "@/hooks/open-project"; import { isNative } from "@/constants/platform"; import { useActiveServerId } from "@/hooks/use-active-server-id"; +import { + buildProjectPickerOptions, + isOpenableProjectPath, + type ProjectPickerOption, +} from "./project-picker-options"; interface PathRowProps { - path: string; + option: ProjectPickerOption; active: boolean; + openPathLabel: string; onSelect: (path: string) => void; } -function PathRow({ path, active, onSelect }: PathRowProps) { +type ProjectPickerErrorCode = NonNullable; + +function getProjectPickerErrorMessage( + result: OpenProjectResult | undefined, + thrownError: Error | null, + translateErrorCode: (errorCode: ProjectPickerErrorCode) => string, + translateOpenFailed: () => string, +): string | null { + if (result?.ok === false) { + if (result.errorCode) { + return translateErrorCode(result.errorCode); + } + return result.error; + } + if (thrownError) { + return thrownError.message || translateOpenFailed(); + } + return null; +} + +function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) { const { theme } = useUnistyles(); + const Icon = option.kind === "path" ? FolderPlus : Folder; + const path = option.path; + const displayPath = shortenPath(path); + const label = option.kind === "path" ? `${openPathLabel}: ${displayPath}` : displayPath; const handlePress = useCallback(() => { onSelect(path); }, [onSelect, path]); @@ -49,10 +79,10 @@ function PathRow({ path, active, onSelect }: PathRowProps) { - + - {shortenPath(path)} + {label} @@ -74,9 +104,25 @@ export function ProjectPickerModal() { const inputRef = useRef(null); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); - const [isSubmitting, setIsSubmitting] = useState(false); const openProject = useOpenProject(serverId); + const openProjectMutation = useMutation({ + mutationFn: (path: string) => openProject(path), + onSuccess: (result) => { + if (result.ok) { + setOpen(false); + } + }, + }); + const { mutate: submitPath, reset: resetSubmit, isPending: isSubmitting } = openProjectMutation; + const submitResult = openProjectMutation.data; + const errorMessage = getProjectPickerErrorMessage( + submitResult, + openProjectMutation.error, + (errorCode) => t(`projectPicker.errors.${errorCode}`), + () => t("projectPicker.errors.open_failed"), + ); + const directorySuggestionsQuery = useQuery({ queryKey: ["project-picker-directory-suggestions", serverId, query], queryFn: async () => { @@ -97,16 +143,11 @@ export function ProjectPickerModal() { }); const options = useMemo(() => { - const suggestedPaths = buildWorkingDirectorySuggestions({ + return buildProjectPickerOptions({ recommendedPaths, serverPaths: directorySuggestionsQuery.data ?? [], query, }); - const trimmedQuery = query.trim(); - if (!trimmedQuery || suggestedPaths.includes(trimmedQuery)) { - return suggestedPaths; - } - return [trimmedQuery, ...suggestedPaths]; }, [query, directorySuggestionsQuery.data, recommendedPaths]); const handleClose = useCallback(() => { @@ -114,43 +155,41 @@ export function ProjectPickerModal() { }, [setOpen]); const handleSelectPath = useCallback( - async (path: string) => { + (path: string) => { const trimmed = path.trim(); if (!trimmed || !client || !serverId) return; - - setIsSubmitting(true); - try { - const didOpenProject = await openProject(trimmed); - if (didOpenProject) { - setOpen(false); - } - } finally { - setIsSubmitting(false); - } + submitPath(trimmed); }, - [client, openProject, serverId, setOpen], + [client, serverId, submitPath], ); const handleSubmitCustom = useCallback(() => { const trimmed = query.trim(); - if (!trimmed) return; - void handleSelectPath(trimmed); + if (!isOpenableProjectPath(trimmed)) return; + handleSelectPath(trimmed); }, [handleSelectPath, query]); - const handleChangeQuery = useCallback((text: string) => { - setQuery(text); - setActiveIndex(0); - }, []); + const handleChangeQuery = useCallback( + (text: string) => { + setQuery(text); + setActiveIndex(0); + resetSubmit(); + }, + [resetSubmit], + ); // Reset state when opening/closing useEffect(() => { - if (open) { - setQuery(""); - setActiveIndex(0); - const id = setTimeout(() => inputRef.current?.focus(), 0); - return () => clearTimeout(id); + resetSubmit(); + if (!open) { + return; } - }, [open]); + + setQuery(""); + setActiveIndex(0); + const id = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(id); + }, [open, resetSubmit]); // Clamp active index useEffect(() => { @@ -170,14 +209,14 @@ export function ProjectPickerModal() { if (key === "Escape") { event.preventDefault(); - setOpen(false); + handleClose(); return; } if (key === "Enter") { event.preventDefault(); if (options.length > 0 && activeIndex < options.length) { - void handleSelectPath(options[activeIndex]); + handleSelectPath(options[activeIndex].path); } else if (query.trim()) { handleSubmitCustom(); } @@ -199,7 +238,7 @@ export function ProjectPickerModal() { window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]); + }, [activeIndex, handleClose, handleSelectPath, handleSubmitCustom, open, options, query]); const panelStyle = useMemo( () => [ @@ -219,6 +258,10 @@ export function ProjectPickerModal() { () => [styles.input, { color: theme.colors.foreground }], [theme.colors.foreground], ); + const errorTextStyle = useMemo( + () => [styles.errorText, { color: theme.colors.destructive }], + [theme.colors.destructive], + ); const emptyTextStyle = useMemo( () => [styles.emptyText, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], @@ -247,6 +290,7 @@ export function ProjectPickerModal() { returnKeyType="go" onSubmitEditing={handleSubmitCustom} /> + {errorMessage ? {errorMessage} : null} {isSubmitting ? {t("projectPicker.opening")} : null} - {!isSubmitting && options.length === 0 && !query.trim() ? ( + {!isSubmitting && options.length === 0 ? ( {t("projectPicker.empty")} ) : null} - {!isSubmitting && !(options.length === 0 && !query.trim()) ? ( + {!isSubmitting && options.length > 0 ? ( <> - {options.map((path, index) => ( + {options.map((option, index) => ( ))} @@ -308,6 +353,11 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], outlineStyle: "none", } as object, + errorText: { + marginTop: theme.spacing[2], + fontSize: theme.fontSize.sm, + lineHeight: 18, + }, results: { flexGrow: 0, }, diff --git a/packages/app/src/components/project-picker-options.test.ts b/packages/app/src/components/project-picker-options.test.ts new file mode 100644 index 000000000..cfe0d4a42 --- /dev/null +++ b/packages/app/src/components/project-picker-options.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { buildProjectPickerOptions, isOpenableProjectPath } from "./project-picker-options"; + +describe("isOpenableProjectPath", () => { + it("accepts POSIX, tilde, Windows drive-letter, and UNC paths", () => { + expect(isOpenableProjectPath("/repo")).toBe(true); + expect(isOpenableProjectPath("~/src")).toBe(true); + expect(isOpenableProjectPath("C:\\Users\\mo")).toBe(true); + expect(isOpenableProjectPath("c:/users/mo")).toBe(true); + expect(isOpenableProjectPath("\\\\server\\share")).toBe(true); + }); + + it("rejects relative input", () => { + expect(isOpenableProjectPath("repo")).toBe(false); + expect(isOpenableProjectPath("repo/sub")).toBe(false); + expect(isOpenableProjectPath("c:repo")).toBe(false); + expect(isOpenableProjectPath("")).toBe(false); + }); +}); + +describe("buildProjectPickerOptions", () => { + it("does not create an open-path row for word queries", () => { + const options = buildProjectPickerOptions({ + recommendedPaths: ["/repo/api"], + serverPaths: ["/repo/app"], + query: "repo", + }); + + expect(options).toEqual([ + { kind: "suggestion", path: "/repo/api" }, + { kind: "suggestion", path: "/repo/app" }, + ]); + }); + + it("puts an absolute path row first", () => { + const options = buildProjectPickerOptions({ + recommendedPaths: ["/repo/api"], + serverPaths: [], + query: "/repo", + }); + + expect(options).toEqual([ + { kind: "path", path: "/repo" }, + { kind: "suggestion", path: "/repo/api" }, + ]); + }); + + it("puts a home-relative path row first", () => { + const options = buildProjectPickerOptions({ + recommendedPaths: ["/Users/mo/src/api"], + serverPaths: [], + query: "~/src", + }); + + expect(options).toEqual([ + { kind: "path", path: "~/src" }, + { kind: "suggestion", path: "/Users/mo/src/api" }, + ]); + }); + + it("creates an open-path row for Windows paths", () => { + const options = buildProjectPickerOptions({ + recommendedPaths: [], + serverPaths: [], + query: "C:\\Users\\mo\\src", + }); + + expect(options).toEqual([{ kind: "path", path: "C:\\Users\\mo\\src" }]); + }); + + it("does not duplicate an existing suggestion", () => { + const options = buildProjectPickerOptions({ + recommendedPaths: ["/repo/api"], + serverPaths: ["/repo/app"], + query: "/repo/app", + }); + + expect(options).toEqual([{ kind: "suggestion", path: "/repo/app" }]); + }); +}); diff --git a/packages/app/src/components/project-picker-options.ts b/packages/app/src/components/project-picker-options.ts new file mode 100644 index 000000000..fc1e11466 --- /dev/null +++ b/packages/app/src/components/project-picker-options.ts @@ -0,0 +1,48 @@ +import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; + +export interface BuildProjectPickerOptionsInput { + recommendedPaths: string[]; + serverPaths: string[]; + query: string; +} + +export interface ProjectPickerPathOption { + kind: "path"; + path: string; +} + +export interface ProjectPickerSuggestionOption { + kind: "suggestion"; + path: string; +} + +export type ProjectPickerOption = ProjectPickerPathOption | ProjectPickerSuggestionOption; + +// Matches the daemon's filesystem semantics, not the client's: POSIX absolute, +// tilde, Windows drive letter (C:\ or C:/), or UNC (\\server\share). +export function isOpenableProjectPath(query: string): boolean { + const trimmedQuery = query.trim(); + return ( + trimmedQuery.startsWith("/") || + trimmedQuery.startsWith("~") || + trimmedQuery.startsWith("\\\\") || + /^[a-zA-Z]:[\\/]/.test(trimmedQuery) + ); +} + +export function buildProjectPickerOptions( + input: BuildProjectPickerOptionsInput, +): ProjectPickerOption[] { + const suggestedPaths = buildWorkingDirectorySuggestions(input); + const suggestions = suggestedPaths.map((path) => ({ + kind: "suggestion", + path, + })); + const trimmedQuery = input.query.trim(); + + if (!isOpenableProjectPath(trimmedQuery) || suggestedPaths.includes(trimmedQuery)) { + return suggestions; + } + + return [{ kind: "path", path: trimmedQuery }, ...suggestions]; +} diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts index bc0dbd585..18aaa0b27 100644 --- a/packages/app/src/hooks/open-project.ts +++ b/packages/app/src/hooks/open-project.ts @@ -1,7 +1,23 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { OpenProjectResponseMessage } from "@getpaseo/protocol/messages"; import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +type OpenProjectPayload = OpenProjectResponseMessage["payload"]; +type OpenProjectErrorCode = NonNullable; + +export interface OpenProjectSuccess { + ok: true; +} + +export interface OpenProjectFailure { + ok: false; + errorCode: OpenProjectErrorCode | null; + error: string | null; +} + +export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure; + export interface OpenProjectDirectlyInput { serverId: string; projectPath: string; @@ -13,16 +29,22 @@ export interface OpenProjectDirectlyInput { navigateToWorkspace: (serverId: string, workspaceId: string) => void; } -export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise { +export async function openProjectDirectly( + input: OpenProjectDirectlyInput, +): Promise { const normalizedServerId = input.serverId.trim(); const trimmedPath = input.projectPath.trim(); if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) { - return false; + return { ok: false, errorCode: null, error: null }; } const payload = await input.client.openProject(trimmedPath); if (payload.error || !payload.workspace) { - return false; + return { + ok: false, + errorCode: payload.errorCode ?? null, + error: payload.error, + }; } const workspace = normalizeWorkspaceDescriptor(payload.workspace); @@ -34,10 +56,10 @@ export async function openProjectDirectly(input: OpenProjectDirectlyInput): Prom workspaceId: workspace.id, }); if (!workspaceKey) { - return false; + return { ok: false, errorCode: null, error: null }; } input.openDraftTab(workspaceKey); input.navigateToWorkspace(normalizedServerId, workspace.id); - return true; + return { ok: true }; } diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 2b6e50e49..f1b0e2110 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { openProjectDirectly } from "@/hooks/open-project"; import type { WorkspaceDescriptor } from "@/stores/session-store"; @@ -91,11 +91,11 @@ describe("openProjectDirectly", () => { projectPath: PROJECT_PATH, isConnected: true, client: { - openProject: vi.fn(async () => ({ + openProject: async () => ({ requestId: "request-1", error: null, workspace: workspacePayload, - })), + }), }, mergeWorkspaces: session.mergeWorkspaces, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, @@ -103,7 +103,7 @@ describe("openProjectDirectly", () => { navigateToWorkspace: navigator.navigateToWorkspace, }); - expect(result).toBe(true); + expect(result).toEqual({ ok: true }); expect(session.merges).toHaveLength(1); expect(session.merges[0]?.serverId).toBe(SERVER_ID); expect(session.merges[0]?.workspaces[0]).toMatchObject({ @@ -127,11 +127,12 @@ describe("openProjectDirectly", () => { projectPath: PROJECT_PATH, isConnected: true, client: { - openProject: vi.fn(async () => ({ + openProject: async () => ({ requestId: "request-2", - error: "Failed to open project", + error: "Directory not found: /repo/project", + errorCode: "directory_not_found" as const, workspace: null, - })), + }), }, mergeWorkspaces: session.mergeWorkspaces, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, @@ -139,7 +140,11 @@ describe("openProjectDirectly", () => { navigateToWorkspace: navigator.navigateToWorkspace, }); - expect(result).toBe(false); + expect(result).toEqual({ + ok: false, + errorCode: "directory_not_found", + error: "Directory not found: /repo/project", + }); expect(session.merges).toEqual([]); expect(session.hydrated).toEqual([]); expect(layout.openedTabs).toEqual([]); diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index cf09064c9..692dec29d 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -4,9 +4,11 @@ import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { generateDraftId } from "@/stores/draft-keys"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; -import { openProjectDirectly } from "@/hooks/open-project"; +import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project"; -export function useOpenProject(serverId: string | null): (path: string) => Promise { +export function useOpenProject( + serverId: string | null, +): (path: string) => Promise { const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index fbcc405a0..f15f5408d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1025,6 +1025,11 @@ export const ar: TranslationResources = { placeholder: "اكتب مسار الدليل...", opening: "افتتاح المشروع...", empty: "ابدأ بكتابة المسار", + errors: { + directory_not_found: "لم يتم العثور على الدليل.", + open_failed: "تعذر فتح المشروع.", + }, + openPath: "فتح المسار", }, branchSwitcher: { currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c79bf3538..96e192047 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1030,6 +1030,11 @@ export const en = { placeholder: "Type a directory path...", opening: "Opening project...", empty: "Start typing a path", + errors: { + directory_not_found: "Directory not found.", + open_failed: "Could not open project.", + }, + openPath: "Open path", }, branchSwitcher: { currentBranch: "Current branch: {{branchName}}. Press to switch branch.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1b6f61589..110a7ff24 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1057,6 +1057,11 @@ export const es: TranslationResources = { placeholder: "Escriba una ruta de directorio...", opening: "Proyecto de apertura...", empty: "Comience a escribir una ruta", + errors: { + directory_not_found: "No se encontró el directorio.", + open_failed: "No se pudo abrir el proyecto.", + }, + openPath: "Abrir ruta", }, branchSwitcher: { currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5a96059fc..4cf40b011 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1059,6 +1059,11 @@ export const fr: TranslationResources = { placeholder: "Tapez un chemin de répertoire...", opening: "Projet d'ouverture...", empty: "Commencez à taper un chemin", + errors: { + directory_not_found: "Répertoire introuvable.", + open_failed: "Impossible d’ouvrir le projet.", + }, + openPath: "Ouvrir le chemin", }, branchSwitcher: { currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 3a61a2354..368b2fb92 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1047,6 +1047,11 @@ export const ru: TranslationResources = { placeholder: "Введите путь к каталогу...", opening: "Открытие проекта...", empty: "Начните вводить путь", + errors: { + directory_not_found: "Каталог не найден.", + open_failed: "Не удалось открыть проект.", + }, + openPath: "Открыть путь", }, branchSwitcher: { currentBranch: "Текущая ветка:{{branchName}}. Нажмите, чтобы переключить ветку.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 021cb366d..8d140bc3a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1010,6 +1010,11 @@ export const zhCN: TranslationResources = { placeholder: "输入目录路径...", opening: "正在打开 project...", empty: "开始输入路径", + errors: { + directory_not_found: "找不到目录。", + open_failed: "无法打开项目。", + }, + openPath: "打开路径", }, branchSwitcher: { currentBranch: "当前分支:{{branchName}}。按下以切换分支。", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1773f39a3..099e0ba27 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2622,6 +2622,8 @@ export const OpenProjectResponseMessageSchema = z.object({ requestId: z.string(), workspace: WorkspaceDescriptorPayloadSchema.nullable(), error: z.string().nullable(), + // Unknown codes from newer daemons degrade to null; clients fall back to `error`. + errorCode: z.enum(["directory_not_found"]).nullish().catch(null), }), }); diff --git a/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts new file mode 100644 index 000000000..ebd93a4ff --- /dev/null +++ b/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts @@ -0,0 +1,93 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, expect, test } from "vitest"; + +import { DaemonClient, type DaemonEvent } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +const cleanupPaths = new Set(); +const cleanupDaemons = new Set(); +const cleanupClients = new Set(); +let previousSupervised: string | undefined; + +beforeEach(() => { + previousSupervised = process.env.PASEO_SUPERVISED; + process.env.PASEO_SUPERVISED = "0"; +}); + +afterEach(async () => { + restoreSupervisedEnv(); + await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined))); + cleanupClients.clear(); + await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined))); + cleanupDaemons.clear(); + await Promise.all( + Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })), + ); + cleanupPaths.clear(); +}); + +function restoreSupervisedEnv(): void { + if (previousSupervised === undefined) { + delete process.env.PASEO_SUPERVISED; + return; + } + + process.env.PASEO_SUPERVISED = previousSupervised; +} + +// Repro for the "project flashes in the sidebar then disappears" report. +// The project picker submits the typed query verbatim, so open_project can +// receive a path that does not exist on the daemon's disk. The daemon must not +// answer with a success + workspace upsert that it immediately retracts. +test("openProject on a nonexistent directory does not broadcast an upsert that is immediately removed", async () => { + const daemon = await createTestPaseoDaemon(); + cleanupDaemons.add(daemon); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanupClients.add(client); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "missing-dir-agents" } }); + + const workspaceEvents: Array<{ kind: "upsert" | "remove"; workspaceId: string }> = []; + client.subscribe((event: DaemonEvent) => { + if (event.type !== "workspace_update") { + return; + } + workspaceEvents.push({ kind: event.payload.kind, workspaceId: event.workspaceId }); + }); + await client.fetchWorkspaces({ subscribe: { subscriptionId: "missing-dir-workspaces" } }); + + const tempParent = await mkdtemp(path.join(os.tmpdir(), "paseo-open-project-")); + cleanupPaths.add(tempParent); + const missingPath = path.join(tempParent, "this-directory-does-not-exist"); + const response = await client.openProject(missingPath); + + expect(response.workspace).toBeNull(); + expect(response.error).toBe(`Directory not found: ${missingPath}`); + expect(response.errorCode).toBe("directory_not_found"); + expect(workspaceEvents).toEqual([]); +}, 30000); + +test("openProject expands tilde before creating the workspace", async () => { + const daemon = await createTestPaseoDaemon(); + cleanupDaemons.add(daemon); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanupClients.add(client); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "tilde-project-agents" } }); + await client.fetchWorkspaces({ subscribe: { subscriptionId: "tilde-project-workspaces" } }); + + const home = process.env.HOME || os.homedir(); + const workspacePath = await mkdtemp(path.join(home, ".paseo-open-project-")); + cleanupPaths.add(workspacePath); + const queryPath = `~/${path.relative(home, workspacePath)}`; + + const response = await client.openProject(queryPath); + + expect(response.error).toBeNull(); + expect(response.errorCode).toBeUndefined(); + expect(response.workspace?.workspaceDirectory).toBe(workspacePath); +}, 30000); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4184f4d63..92fbde31c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2,6 +2,7 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { realpathSync } from "node:fs"; import type { FSWatcher } from "node:fs"; +import { stat } from "node:fs/promises"; import { basename, resolve, sep } from "path"; import { homedir } from "node:os"; import { z } from "zod"; @@ -533,6 +534,17 @@ interface AudioBufferState { totalPCMBytes: number; } +export interface SessionFileSystem { + isDirectory(path: string): Promise; +} + +const nodeSessionFileSystem: SessionFileSystem = { + async isDirectory(path) { + const stats = await stat(path).catch(() => null); + return stats?.isDirectory() ?? false; + }, +}; + // Stub types for features under development (modules not yet available) type AgentMcpTransportFactory = () => Promise; @@ -564,6 +576,7 @@ export interface SessionOptions { agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + filesystem?: SessionFileSystem; chatService: FileBackedChatService; scheduleService: ScheduleService; loopService: LoopService; @@ -719,6 +732,19 @@ interface AgentTimelineProjectionSelection { hasNewer: boolean; } +type RegistryTransition = "created" | "unarchived" | "existing"; + +interface ArchivedRecordSnapshot { + archivedAt?: string | null; +} + +function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition { + if (!record) { + return "created"; + } + return record.archivedAt ? "unarchived" : "existing"; +} + /** * Session represents a single connected client session. * It owns all state management, orchestration logic, and message processing. @@ -770,6 +796,7 @@ export class Session { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly filesystem: SessionFileSystem; private readonly chatService: FileBackedChatService; private readonly scheduleService: ScheduleService; private readonly loopService: LoopService; @@ -844,6 +871,7 @@ export class Session { agentStorage, projectRegistry, workspaceRegistry, + filesystem, chatService, scheduleService, loopService, @@ -893,6 +921,7 @@ export class Session { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry; this.workspaceRegistry = workspaceRegistry; + this.filesystem = filesystem ?? nodeSessionFileSystem; this.chatService = chatService; this.scheduleService = scheduleService; this.loopService = loopService; @@ -6755,9 +6784,10 @@ export class Session { } private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { + const archiveTimestamp = archivedAt ?? new Date().toISOString(); const existingWorkspace = await archivePersistedWorkspaceRecord({ workspaceId, - archivedAt, + archivedAt: archiveTimestamp, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, }); @@ -6766,6 +6796,22 @@ export class Session { return; } + if (!existingWorkspace.archivedAt) { + const activeSiblings = (await this.workspaceRegistry.list()).filter( + (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, + ); + this.sessionLogger.info( + { + workspaceId, + workspaceCwd: existingWorkspace.cwd, + projectId: existingWorkspace.projectId, + projectArchived: activeSiblings.length === 0, + archivedAt: archiveTimestamp, + }, + "Workspace archived", + ); + } + await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd); this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd); this.removeWorkspaceGitSubscription(workspaceId); @@ -7156,11 +7202,58 @@ export class Session { private async handleOpenProjectRequest( request: Extract, ): Promise { + const requestedCwd = request.cwd; + const cwd = expandTilde(requestedCwd); + const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); + if (!directoryExists) { + this.sessionLogger.info( + { requestedCwd, resolvedCwd: cwd, reason: "directory_not_found" }, + "Open project rejected", + ); + this.emit({ + type: "open_project_response", + payload: { + requestId: request.requestId, + workspace: null, + error: `Directory not found: ${cwd}`, + errorCode: "directory_not_found", + }, + }); + return; + } + try { - const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd); + const projectsBefore = new Map(); + for (const project of await this.projectRegistry.list()) { + projectsBefore.set(project.projectId, project); + } + const workspacesBefore = new Map(); + for (const workspaceRecord of await this.workspaceRegistry.list()) { + workspacesBefore.set(workspaceRecord.workspaceId, workspaceRecord); + } + const workspace = await this.findOrCreateWorkspaceForDirectory(cwd); + const project = await this.projectRegistry.get(workspace.projectId); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); await this.emitWorkspaceUpdateForCwd(workspace.cwd); + this.sessionLogger.info( + { + requestedCwd, + resolvedCwd: cwd, + workspaceCwd: workspace.cwd, + workspaceId: workspace.workspaceId, + workspaceKind: workspace.kind, + workspaceTransition: describeRegistryTransition( + workspacesBefore.get(workspace.workspaceId) ?? null, + ), + projectId: workspace.projectId, + projectKind: project?.kind ?? null, + projectTransition: describeRegistryTransition( + projectsBefore.get(workspace.projectId) ?? null, + ), + }, + "Project opened", + ); this.emit({ type: "open_project_response", payload: { @@ -7183,7 +7276,7 @@ export class Session { }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to open project"; - this.sessionLogger.error({ err: error, cwd: request.cwd }, "Failed to open project"); + this.sessionLogger.error({ err: error, cwd }, "Failed to open project"); this.emit({ type: "open_project_response", payload: { diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 4ce0d17a2..2103824e3 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -138,6 +138,7 @@ function createHarness(input: { workspaces.delete(id); }, }), + filesystem: { isDirectory: async () => true }, chatService: createStub({}), scheduleService: createStub({}), loopService: createStub({}), diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 2012bc8f0..49a21da9f 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -512,6 +512,7 @@ function createSessionForWorkspaceTests( archive: async () => {}, remove: async () => {}, }, + filesystem: { isDirectory: async () => true }, chatService: asChatService(), scheduleService: asScheduleService(), loopService: asLoopService(), diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index a7655fe91..8fa782dc1 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -73,6 +73,26 @@ function createTestLogger() { return logger as unknown as pino.Logger; } +interface CapturedLogRecord { + message: string; + payload: unknown; +} + +function createCapturingLogger() { + const infoRecords: CapturedLogRecord[] = []; + const logger = { + child: () => logger, + trace: () => undefined, + debug: () => undefined, + info: (payload: unknown, message?: string) => { + infoRecords.push({ payload, message: message ?? "" }); + }, + warn: () => undefined, + error: () => undefined, + }; + return { logger: logger as unknown as pino.Logger, infoRecords }; +} + function createWorkspaceGitServiceStub( metadataByCwd: Record< string, @@ -692,4 +712,80 @@ describe("WorkspaceReconciliationService", () => { expect(onChanges).toHaveBeenCalledTimes(1); expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0); }); + + test("logs reconciliation changes with affected paths and reasons", async () => { + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const { logger, infoRecords } = createCapturingLogger(); + + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath: "/tmp/does-not-exist-log-test", + kind: "non_git", + displayName: "ghost", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: "/tmp/does-not-exist-log-test", + kind: "directory", + displayName: "ghost", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + }); + + await service.runOnce(); + + expect(infoRecords).toEqual([ + { + message: "Workspace reconciliation applied changes", + payload: expect.objectContaining({ + changeCount: 2, + changes: expect.arrayContaining([ + { + kind: "workspace_archived", + workspaceId: "w1", + directory: "/tmp/does-not-exist-log-test", + reason: "directory_missing", + }, + { + kind: "project_archived", + projectId: "p1", + directory: "/tmp/does-not-exist-log-test", + reason: "no_active_workspaces", + }, + ]), + durationMs: expect.any(Number), + }), + }, + ]); + }); + + test("does not log reconciliation when no changes are applied", async () => { + const { projectRegistry, workspaceRegistry } = createTestRegistries(); + const { logger, infoRecords } = createCapturingLogger(); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + }); + + await service.runOnce(); + + expect(infoRecords).toEqual([]); + }); }); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 2124fee7d..4225b1855 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -109,13 +109,7 @@ export class WorkspaceReconciliationService { if (this.running) return; this.running = true; try { - const result = await this.reconcile(); - if (result.changesApplied.length > 0) { - this.logger.info( - { changeCount: result.changesApplied.length, durationMs: result.durationMs }, - "Reconciliation pass completed with changes", - ); - } + await this.reconcile(); } catch (error) { this.logger.error({ err: error }, "Reconciliation pass failed"); } finally { @@ -201,7 +195,19 @@ export class WorkspaceReconciliationService { this.onChanges(changes); } - return { changesApplied: changes, durationMs: Date.now() - start }; + const result = { changesApplied: changes, durationMs: Date.now() - start }; + if (changes.length > 0) { + this.logger.info( + { + changeCount: changes.length, + durationMs: result.durationMs, + changes, + }, + "Workspace reconciliation applied changes", + ); + } + + return result; } private async mergeDuplicateProjectsByRoot(