diff --git a/apps/web/src/components/AddProjectDialog.browser.tsx b/apps/web/src/components/AddProjectDialog.browser.tsx index 387ba78c..badb1558 100644 --- a/apps/web/src/components/AddProjectDialog.browser.tsx +++ b/apps/web/src/components/AddProjectDialog.browser.tsx @@ -17,6 +17,7 @@ function installNativeApi(overrides: { cloneSource?: NativeApi["projects"]["cloneSource"]; browse?: NativeApi["filesystem"]["browse"]; createDirectory?: NativeApi["filesystem"]["createDirectory"]; + pickFolder?: NativeApi["dialogs"]["pickFolder"]; }) { const previousNativeApi = window.nativeApi; const baseApi = readNativeApi(); @@ -40,7 +41,7 @@ function installNativeApi(overrides: { }, dialogs: { ...baseApi.dialogs, - pickFolder: vi.fn().mockResolvedValue(null), + pickFolder: overrides.pickFolder ?? vi.fn().mockResolvedValue(null), }, } satisfies NativeApi, }); @@ -71,6 +72,60 @@ function renderDialog( return queryClient; } +function installDesktopFilePathResolver(resolvePath: (file: File) => string | null) { + const previousDesktopBridge = window.desktopBridge; + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: { + ...previousDesktopBridge, + getPathForFile: resolvePath, + }, + }); + return () => { + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: previousDesktopBridge, + }); + }; +} + +function makeFolderDragTransfer( + entries: ReadonlyArray<{ readonly file: File; readonly directory: boolean | "unknown" }>, +): DataTransfer { + return { + types: ["Files"], + items: entries.map(({ file, directory }) => ({ + kind: "file", + type: file.type, + getAsFile: () => file, + ...(directory === "unknown" ? {} : { webkitGetAsEntry: () => ({ isDirectory: directory }) }), + })), + files: entries.map(({ file }) => file), + dropEffect: "none", + effectAllowed: "all", + } as unknown as DataTransfer; +} + +function dispatchDrag( + target: EventTarget, + type: "dragenter" | "dragover" | "dragleave" | "drop", + dataTransfer: DataTransfer, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); + target.dispatchEvent(event); + return event; +} + +function dispatchDialogDrag( + type: "dragenter" | "dragover" | "dragleave" | "drop", + dataTransfer: DataTransfer, +) { + const card = document.querySelector('[data-testid="add-project-dialog-card"]'); + if (!card) throw new Error("Expected folder-drop dialog card."); + return dispatchDrag(card, type, dataTransfer); +} + describe("AddProjectDialog", () => { afterEach(() => { vi.restoreAllMocks(); @@ -127,6 +182,394 @@ describe("AddProjectDialog", () => { restore(); }); + it("shows a compact resting folder-drop row with its icon centered in the tile", async () => { + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ + parentPath: "/Users/tester", + entries: [ + { name: "Documents", fullPath: "/Users/tester/Documents" }, + ...Array.from({ length: 24 }, (_, index) => ({ + name: `Project ${String(index + 1).padStart(2, "0")}`, + fullPath: `/Users/tester/Project ${String(index + 1).padStart(2, "0")}`, + })), + ], + }), + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/Research"); + renderDialog(); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const row = page.getByTestId("folder-drop-affordance"); + const tile = page.getByTestId("folder-drop-icon-tile"); + await expect.element(row).toHaveAttribute("data-drop-state", "idle"); + await expect.element(row).toHaveTextContent("Drop your folder here or browse below"); + + const rowRect = (await row.element()).getBoundingClientRect(); + const tileRect = (await tile.element()).getBoundingClientRect(); + expect(rowRect.height).toBeLessThanOrEqual(52); + expect( + Math.abs(tileRect.top + tileRect.height / 2 - (rowRect.top + rowRect.height / 2)), + ).toBeLessThanOrEqual(1); + await expect.element(page.getByText("Documents", { exact: true })).toBeVisible(); + + const listbox = page.getByRole("listbox"); + const listboxElement = await listbox.element(); + expect(rowRect.bottom).toBeLessThanOrEqual(listboxElement.getBoundingClientRect().top + 1); + expect(listboxElement.querySelector('[role="status"]')).toBeNull(); + listboxElement.scrollTop = listboxElement.scrollHeight; + await expect.element(row).toBeVisible(); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("opens the native folder picker from the folder-plus tile with synchronous single-flight", async () => { + let resolveAdd!: (shouldClose: boolean) => void; + const onAddProjectPath = vi.fn( + () => + new Promise((resolve) => { + resolveAdd = resolve; + }), + ); + const pickFolder = vi.fn().mockResolvedValue("/Users/tester/Research"); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ parentPath: "/Users/tester", entries: [] }), + pickFolder, + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/Research"); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const tile = page.getByTestId("folder-drop-icon-tile"); + const normalizedPlatform = navigator.platform.toLowerCase(); + const expectedFileManagerLabel = normalizedPlatform.includes("mac") + ? "Open in Finder" + : normalizedPlatform.includes("win") + ? "Open in File Explorer" + : "Open in file manager"; + await expect.element(tile).toHaveAccessibleName(expectedFileManagerLabel); + const tileElement = (await tile.element()) as HTMLButtonElement; + tileElement.click(); + tileElement.click(); + + await vi.waitFor(() => expect(pickFolder).toHaveBeenCalledTimes(1)); + expect(onAddProjectPath).toHaveBeenCalledTimes(1); + expect(onAddProjectPath).toHaveBeenCalledWith("/Users/tester/Research", { + createIfMissing: false, + }); + + resolveAdd(false); + await expect + .element(page.getByPlaceholder("Type or browse a folder path")) + .toHaveValue("/Users/tester/Research"); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("does not advertise folder drop when the desktop path bridge is unavailable", async () => { + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ parentPath: "/Users/tester", entries: [] }), + }); + const previousDesktopBridge = window.desktopBridge; + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: undefined, + }); + renderDialog(); + + try { + await page.getByText("Local folder", { exact: true }).click(); + await expect.element(page.getByTestId("folder-drop-affordance")).not.toBeInTheDocument(); + await expect.element(page.getByText("Directories", { exact: true })).toBeVisible(); + } finally { + Object.defineProperty(window, "desktopBridge", { + configurable: true, + value: previousDesktopBridge, + }); + restoreApi(); + } + }); + + it("accepts folder drag feedback across the dialog card and safely rejects backdrop drops", async () => { + const onAddProjectPath = vi.fn().mockResolvedValue(false); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ + parentPath: "/Users/tester", + entries: [{ name: "Documents", fullPath: "/Users/tester/Documents" }], + }), + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/Research"); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const folder = new File([new Blob([])], "Research"); + const transfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + const targets = [ + await page.getByPlaceholder("Type or browse a folder path").element(), + await page.getByText("Documents", { exact: true }).element(), + await page.getByTestId("folder-drop-affordance").element(), + document.querySelector('[data-slot="command-footer"]'), + ]; + + for (const target of targets) { + expect(target).not.toBeNull(); + dispatchDrag(target!, "dragenter", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "active"); + const dragOver = dispatchDrag(target!, "dragover", transfer); + expect(dragOver.defaultPrevented).toBe(true); + expect(transfer.dropEffect).toBe("copy"); + dispatchDrag(target!, "dragleave", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "idle"); + } + + const backdrop = document.querySelector('[data-slot="command-dialog-backdrop"]'); + expect(backdrop).not.toBeNull(); + const outsideTransfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + const outsideOver = dispatchDrag(backdrop!, "dragover", outsideTransfer); + expect(outsideOver.defaultPrevented).toBe(true); + expect(outsideTransfer.dropEffect).toBe("none"); + const outsideDrop = dispatchDrag(backdrop!, "drop", outsideTransfer); + expect(outsideDrop.defaultPrevented).toBe(true); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "idle"); + expect(onAddProjectPath).not.toHaveBeenCalled(); + + await page.getByRole("button", { name: "Back", exact: true }).click(); + const afterCleanupTransfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + const afterCleanupOver = dispatchDrag(backdrop!, "dragover", afterCleanupTransfer); + const afterCleanupDrop = dispatchDrag(backdrop!, "drop", afterCleanupTransfer); + expect(afterCleanupOver.defaultPrevented).toBe(false); + expect(afterCleanupDrop.defaultPrevented).toBe(false); + expect(onAddProjectPath).not.toHaveBeenCalled(); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("accepts a folder dropped inside the card with copy feedback and synchronous single-flight", async () => { + let resolveAdd!: (shouldClose: boolean) => void; + const onAddProjectPath = vi.fn( + () => + new Promise((resolve) => { + resolveAdd = resolve; + }), + ); + const browse = vi.fn(async ({ partialPath }: { partialPath: string }) => + partialPath === "/Users/tester/Research Projects/Study (2)/" + ? { parentPath: "/Users/tester/Research Projects/Study (2)", entries: [] } + : { + parentPath: "/Users/tester", + entries: [{ name: "Documents", fullPath: "/Users/tester/Documents" }], + }, + ); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse, + }); + const restoreBridge = installDesktopFilePathResolver( + () => "/Users/tester/Research Projects/Study (2)", + ); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const folder = new File([new Blob([])], "Study (2)"); + const transfer = makeFolderDragTransfer([{ file: folder, directory: "unknown" }]); + + dispatchDialogDrag("dragenter", transfer); + await expect + .element(page.getByText("Release to add this folder", { exact: true })) + .toBeVisible(); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "active"); + await expect.element(page.getByText("Documents", { exact: true })).toBeVisible(); + + const dragOver = dispatchDialogDrag("dragover", transfer); + expect(dragOver.defaultPrevented).toBe(true); + expect(transfer.dropEffect).toBe("copy"); + + dispatchDialogDrag("drop", transfer); + await vi.waitFor(() => + expect(onAddProjectPath).toHaveBeenCalledWith("/Users/tester/Research Projects/Study (2)", { + createIfMissing: false, + }), + ); + expect(browse).toHaveBeenCalledWith({ + partialPath: "/Users/tester/Research Projects/Study (2)/", + }); + expect(onAddProjectPath).toHaveBeenCalledTimes(1); + + const secondTransfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + dispatchDialogDrag("drop", secondTransfer); + expect(onAddProjectPath).toHaveBeenCalledTimes(1); + + resolveAdd(false); + await expect + .element(page.getByPlaceholder("Type or browse a folder path")) + .toHaveValue("/Users/tester/Research Projects/Study (2)"); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("clears drag feedback on leave and rejects file drops without hiding folders", async () => { + const onAddProjectPath = vi.fn().mockResolvedValue(true); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ + parentPath: "/Users/tester", + entries: [{ name: "Documents", fullPath: "/Users/tester/Documents" }], + }), + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/notes.md"); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const file = new File(["notes"], "notes.md", { type: "text/markdown" }); + const transfer = makeFolderDragTransfer([{ file, directory: false }]); + + dispatchDialogDrag("dragenter", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "idle"); + const dragOver = dispatchDialogDrag("dragover", transfer); + expect(dragOver.defaultPrevented).toBe(true); + expect(transfer.dropEffect).toBe("none"); + dispatchDialogDrag("dragleave", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveTextContent("Drop your folder here or browse below"); + + dispatchDialogDrag("drop", transfer); + const alert = page.getByRole("alert"); + await expect.element(alert).toHaveTextContent("Drop a folder, not a file."); + expect((await page.getByRole("listbox").element()).contains(await alert.element())).toBe( + false, + ); + for (const viewport of [ + { width: 1_280, height: 800 }, + { width: 600, height: 480 }, + ]) { + await page.viewport(viewport.width, viewport.height); + const popup = document.querySelector('[data-slot="command-dialog-popup"]'); + const footer = document.querySelector('[data-slot="command-footer"]'); + expect(popup).not.toBeNull(); + expect(footer).not.toBeNull(); + const popupRect = popup!.getBoundingClientRect(); + const alertRect = (await alert.element()).getBoundingClientRect(); + const footerRect = footer!.getBoundingClientRect(); + const footerStyle = getComputedStyle(footer!); + expect(alertRect.height).toBeGreaterThan(0); + expect(alertRect.top).toBeGreaterThanOrEqual(popupRect.top - 1); + expect(alertRect.bottom).toBeLessThanOrEqual(popupRect.bottom + 1); + expect(footerRect.bottom).toBeLessThanOrEqual(popupRect.bottom + 1); + if (viewport.width >= 640) { + expect(footerStyle.flexDirection).toBe("row"); + expect(footerRect.height).toBeLessThanOrEqual(64); + } else { + expect(footerStyle.flexDirection).toBe("column"); + expect(footerRect.height).toBeLessThanOrEqual(112); + } + } + await expect.element(page.getByText("Documents", { exact: true })).toBeVisible(); + expect(onAddProjectPath).not.toHaveBeenCalled(); + } finally { + await page.viewport(1_280, 720); + restoreBridge(); + restoreApi(); + } + }); + + it("validates an opaque Finder item before submitting it as a project folder", async () => { + const onAddProjectPath = vi.fn().mockResolvedValue(true); + const browse = vi.fn(async ({ partialPath }: { partialPath: string }) => { + if (partialPath === "/Users/tester/notes.md/") { + throw new Error("ENOTDIR"); + } + return { parentPath: "/Users/tester", entries: [] }; + }); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse, + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/notes.md"); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const file = new File(["notes"], "notes.md", { type: "text/markdown" }); + const transfer = makeFolderDragTransfer([{ file, directory: "unknown" }]); + + dispatchDialogDrag("dragenter", transfer); + await expect + .element(page.getByTestId("folder-drop-affordance")) + .toHaveAttribute("data-drop-state", "active"); + dispatchDialogDrag("drop", transfer); + + await expect + .element(page.getByRole("alert")) + .toHaveTextContent("Drop an accessible folder, not a file."); + expect(browse).toHaveBeenCalledWith({ partialPath: "/Users/tester/notes.md/" }); + expect(onAddProjectPath).not.toHaveBeenCalled(); + } finally { + restoreBridge(); + restoreApi(); + } + }); + + it("rejects whitespace-ended dropped folders without submitting a different path", async () => { + const onAddProjectPath = vi.fn().mockResolvedValue(true); + const restoreApi = installNativeApi({ + statuses: vi.fn().mockResolvedValue({ sources: [] }), + browse: vi.fn().mockResolvedValue({ + parentPath: "/Users/tester", + entries: [{ name: "Documents", fullPath: "/Users/tester/Documents" }], + }), + }); + const restoreBridge = installDesktopFilePathResolver(() => "/Users/tester/Research "); + renderDialog(onAddProjectPath); + + try { + await page.getByText("Local folder", { exact: true }).click(); + const folder = new File([new Blob([])], "Research "); + const transfer = makeFolderDragTransfer([{ file: folder, directory: true }]); + + dispatchDialogDrag("drop", transfer); + await expect + .element( + page.getByText( + "Folders with names ending in whitespace are not supported. Rename the folder and try again.", + { exact: true }, + ), + ) + .toBeVisible(); + await expect.element(page.getByText("Documents", { exact: true })).toBeVisible(); + expect(onAddProjectPath).not.toHaveBeenCalled(); + } finally { + restoreBridge(); + restoreApi(); + } + }); + it("browses through a whitespace-ended folder and selects its child", async () => { const browse = vi.fn(async ({ partialPath }: { partialPath: string }) => { if (partialPath === "/Users/tester/") { diff --git a/apps/web/src/components/AddProjectDialog.logic.test.ts b/apps/web/src/components/AddProjectDialog.logic.test.ts index 5f4ffef1..25056aec 100644 --- a/apps/web/src/components/AddProjectDialog.logic.test.ts +++ b/apps/web/src/components/AddProjectDialog.logic.test.ts @@ -1,13 +1,172 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildCloneProjectSourceInput, + canAcceptProjectFolderDrop, getAvailableNewFolderName, inferCloneDirectoryName, + isProjectFolderDrag, joinProjectPath, + resolveDroppedProjectFolder, } from "./AddProjectDialog.logic"; +function makeFile(name: string): File { + return new File([new Blob([])], name); +} + +function makeDropItem(file: File, options?: { directory?: boolean }) { + return { + kind: "file", + getAsFile: () => file, + webkitGetAsEntry: () => ({ isDirectory: options?.directory === true }), + }; +} + describe("AddProjectDialog logic", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("recognizes OS file drags without treating internal text drags as folders", () => { + expect(isProjectFolderDrag(["Files", "text/plain"])).toBe(true); + expect(isProjectFolderDrag(["text/plain"])).toBe(false); + }); + + it("offers acceptance feedback only for one real directory", () => { + const folder = makeFile("Research"); + const file = makeFile("notes.md"); + + expect( + canAcceptProjectFolderDrop({ + items: [makeDropItem(folder, { directory: true })], + files: [folder], + }), + ).toBe(true); + expect(canAcceptProjectFolderDrop({ items: [makeDropItem(file)], files: [file] })).toBe(false); + expect( + canAcceptProjectFolderDrop({ + items: [ + makeDropItem(folder, { directory: true }), + makeDropItem(makeFile("Second"), { directory: true }), + ], + files: [folder], + }), + ).toBe(false); + expect( + canAcceptProjectFolderDrop({ + items: [{ kind: "file", getAsFile: () => folder }], + files: [], + }), + ).toBe(true); + expect(canAcceptProjectFolderDrop({ items: [], files: [] })).toBe(true); + }); + + it("resolves one dropped directory through the Electron path bridge", () => { + const folder = makeFile("Research"); + vi.stubGlobal("window", { + desktopBridge: { + getPathForFile: () => "/Users/tester/Research (2)", + }, + }); + + expect( + resolveDroppedProjectFolder({ + items: [makeDropItem(folder, { directory: true })], + files: [folder], + }), + ).toEqual({ path: "/Users/tester/Research (2)" }); + }); + + it("falls back to the transfer file when Chromium cannot expose the item file", () => { + const folder = makeFile("Research"); + vi.stubGlobal("window", { + desktopBridge: { + getPathForFile: () => "/Users/tester/Research", + }, + }); + + expect( + resolveDroppedProjectFolder({ + items: [ + { + kind: "file", + getAsFile: () => { + throw new Error("unavailable"); + }, + webkitGetAsEntry: () => ({ isDirectory: true }), + }, + ], + files: [folder], + }), + ).toEqual({ path: "/Users/tester/Research" }); + }); + + it("resolves an opaque Finder item for native directory validation", () => { + const folder = makeFile("Research"); + vi.stubGlobal("window", { + desktopBridge: { + getPathForFile: () => "/Users/tester/Research", + }, + }); + + expect( + resolveDroppedProjectFolder({ + items: [{ kind: "file", getAsFile: () => folder }], + files: [folder], + }), + ).toEqual({ path: "/Users/tester/Research", requiresDirectoryValidation: true }); + }); + + it("rejects files, ambiguous multi-folder drops, and unavailable absolute paths", () => { + const file = makeFile("notes.md"); + const folder = makeFile("Research"); + vi.stubGlobal("window", { desktopBridge: { getPathForFile: () => null } }); + + expect(resolveDroppedProjectFolder({ items: [makeDropItem(file)], files: [file] })).toEqual({ + error: "Drop a folder, not a file.", + }); + expect( + resolveDroppedProjectFolder({ + items: [ + makeDropItem(folder, { directory: true }), + makeDropItem(makeFile("Second"), { directory: true }), + ], + files: [folder], + }), + ).toEqual({ error: "Drop one folder at a time." }); + expect( + resolveDroppedProjectFolder({ + items: [], + files: [folder, makeFile("Second")], + }), + ).toEqual({ error: "Drop one folder at a time." }); + expect( + resolveDroppedProjectFolder({ + items: [makeDropItem(folder, { directory: true })], + files: [folder], + }), + ).toEqual({ error: "Could not read the folder's path. Use browse below instead." }); + }); + + it("rejects a dropped path that downstream project normalization would trim", () => { + const folder = makeFile("Research "); + vi.stubGlobal("window", { + desktopBridge: { + getPathForFile: () => "/Users/tester/Research ", + }, + }); + + expect( + resolveDroppedProjectFolder({ + items: [makeDropItem(folder, { directory: true })], + files: [folder], + }), + ).toEqual({ + error: + "Folders with names ending in whitespace are not supported. Rename the folder and try again.", + }); + }); + it("derives stable destination folder names from supported repository inputs", () => { expect(inferCloneDirectoryName("git-url", "https://github.com/owner/repo.git")).toBe("repo"); expect(inferCloneDirectoryName("git-url", "git@gitlab.com:group/repo.git")).toBe("repo"); diff --git a/apps/web/src/components/AddProjectDialog.logic.ts b/apps/web/src/components/AddProjectDialog.logic.ts index 7cda76bb..86ece051 100644 --- a/apps/web/src/components/AddProjectDialog.logic.ts +++ b/apps/web/src/components/AddProjectDialog.logic.ts @@ -1,7 +1,96 @@ import type { CloneProjectSourceInput, RepositoryProvider } from "@synara/contracts"; +import { + resolveDroppedFileAbsolutePath, + type ComposerDroppedFileItem, +} from "~/lib/composerDropPaths"; + export type AddProjectSource = "local" | "git-url" | RepositoryProvider; +export type DroppedProjectFolderResult = + | { readonly path: string; readonly requiresDirectoryValidation?: true } + | { readonly error: string }; + +export interface ProjectFolderDataTransfer { + readonly items: Iterable; + readonly files: Iterable; +} + +export function isProjectFolderDrag(types: Iterable): boolean { + return Array.from(types).includes("Files"); +} + +function getDroppedItemKind( + item: ComposerDroppedFileItem | undefined, +): "directory" | "file" | "unknown" { + if (!item || typeof item.webkitGetAsEntry !== "function") { + return "unknown"; + } + try { + const entry = item.webkitGetAsEntry(); + if (!entry) return "unknown"; + return entry.isDirectory === true ? "directory" : "file"; + } catch { + return "unknown"; + } +} + +export function canAcceptProjectFolderDrop(dataTransfer: ProjectFolderDataTransfer): boolean { + const fileItems = Array.from(dataTransfer.items).filter((item) => item.kind === "file"); + const files = Array.from(dataTransfer.files); + if (fileItems.length > 1 || files.length > 1) return false; + + // Finder and some Electron/Chromium versions withhold both File objects and + // webkit entries until `drop`. Keep the surface receptive while the payload + // is opaque, then validate the resolved path before opening it. + return getDroppedItemKind(fileItems[0]) !== "file"; +} + +function getDroppedProjectFile( + item: ComposerDroppedFileItem | undefined, + files: Iterable, +): File | null { + try { + return item?.getAsFile() ?? Array.from(files)[0] ?? null; + } catch { + return Array.from(files)[0] ?? null; + } +} + +export function resolveDroppedProjectFolder( + dataTransfer: ProjectFolderDataTransfer, +): DroppedProjectFolderResult { + const fileItems = Array.from(dataTransfer.items).filter((item) => item.kind === "file"); + const files = Array.from(dataTransfer.files); + if (fileItems.length > 1 || files.length > 1) { + return { error: "Drop one folder at a time." }; + } + + const item = fileItems[0]; + const file = getDroppedProjectFile(item, files); + if (!file) { + return { error: "Could not read the dropped folder. Use browse below instead." }; + } + const itemKind = getDroppedItemKind(item); + if (itemKind === "file") { + return { error: "Drop a folder, not a file." }; + } + + const absolutePath = resolveDroppedFileAbsolutePath(file); + if (!absolutePath) { + return { error: "Could not read the folder's path. Use browse below instead." }; + } + if (absolutePath !== absolutePath.trim()) { + return { + error: + "Folders with names ending in whitespace are not supported. Rename the folder and try again.", + }; + } + return itemKind === "unknown" + ? { path: absolutePath, requiresDirectoryValidation: true } + : { path: absolutePath }; +} + export function inferCloneDirectoryName(source: AddProjectSource, value: string): string { const trimmed = value .trim() diff --git a/apps/web/src/components/AddProjectDialog.tsx b/apps/web/src/components/AddProjectDialog.tsx index 2304b7f8..6065cebf 100644 --- a/apps/web/src/components/AddProjectDialog.tsx +++ b/apps/web/src/components/AddProjectDialog.tsx @@ -4,7 +4,7 @@ import type { RepositorySourceStatus, } from "@synara/contracts"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { SiGithub, SiGitlab } from "react-icons/si"; import { LuArrowLeft, LuCornerLeftUp, LuFolderPlus, LuLink } from "react-icons/lu"; @@ -40,10 +40,13 @@ import { import { Kbd, KbdGroup } from "./ui/kbd"; import { buildCloneProjectSourceInput, + canAcceptProjectFolderDrop, getAvailableNewFolderName, type AddProjectSource, inferCloneDirectoryName, + isProjectFolderDrag, joinProjectPath, + resolveDroppedProjectFolder, } from "./AddProjectDialog.logic"; const BROWSE_STALE_TIME_MS = 10_000; @@ -65,6 +68,8 @@ interface PathBrowserProps { busyLabel: string; isBusy: boolean; cloneDirectoryName?: string; + acceptFolderDrop?: boolean; + folderDropTarget?: HTMLElement | null; onBack: () => void; onSubmit: (path: string, options: { createIfMissing: boolean }) => Promise; } @@ -141,11 +146,15 @@ function DialogBackButton(props: { label: string; onClick: () => void }) { } function ProjectPathBrowser(props: PathBrowserProps & { homeDir: string | null }) { + const { isBusy, onSubmit } = props; const [query, setQuery] = useState(props.initialQuery); const [highlightedValue, setHighlightedValue] = useState(null); const [error, setError] = useState(null); + const [isFolderDragActive, setIsFolderDragActive] = useState(false); const inputRef = useRef(null); const submitInFlightRef = useRef(false); + const supportsFolderDrop = + props.acceptFolderDrop === true && typeof window.desktopBridge?.getPathForFile === "function"; const pendingNameSelectionRef = useRef<{ query: string; start: number; end: number } | null>( null, ); @@ -315,6 +324,140 @@ function ProjectPathBrowser(props: PathBrowserProps & { homeDir: string | null } props.onBack(); }; + const runSubmission = useCallback( + async (operation: () => Promise) => { + if (isBusy || submitInFlightRef.current) return; + submitInFlightRef.current = true; + setError(null); + try { + await operation(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Unable to add project."); + } finally { + submitInFlightRef.current = false; + } + }, + [isBusy], + ); + + const pickAndSubmitFolder = useCallback(() => { + void runSubmission(async () => { + const api = readNativeApi(); + if (!api) throw new Error("The app server is unavailable."); + const picked = await api.dialogs.pickFolder(); + if (!picked) return; + const target = props.cloneDirectoryName + ? joinProjectPath(picked, props.cloneDirectoryName) + : picked; + setQuery(target); + await onSubmit(target, { createIfMissing: false }); + }); + }, [onSubmit, props.cloneDirectoryName, runSubmission]); + + useEffect(() => { + if (!supportsFolderDrop) { + setIsFolderDragActive(false); + return; + } + + const dropTarget = props.folderDropTarget; + if (!dropTarget) return; + const ownerDocument = dropTarget.ownerDocument; + + let dragDepth = 0; + const resetDragState = () => { + dragDepth = 0; + setIsFolderDragActive(false); + }; + const handleDragEnter = (event: globalThis.DragEvent) => { + if (!event.dataTransfer || !isProjectFolderDrag(event.dataTransfer.types)) return; + dragDepth += 1; + setIsFolderDragActive( + !isBusy && !submitInFlightRef.current && canAcceptProjectFolderDrop(event.dataTransfer), + ); + }; + const handleDragOver = (event: globalThis.DragEvent) => { + if (!event.dataTransfer || !isProjectFolderDrag(event.dataTransfer.types)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = + !isBusy && !submitInFlightRef.current && canAcceptProjectFolderDrop(event.dataTransfer) + ? "copy" + : "none"; + }; + const handleDragLeave = (event: globalThis.DragEvent) => { + if (!event.dataTransfer || !isProjectFolderDrag(event.dataTransfer.types)) return; + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) setIsFolderDragActive(false); + }; + const handleDrop = (event: globalThis.DragEvent) => { + if (!event.dataTransfer || !isProjectFolderDrag(event.dataTransfer.types)) return; + event.preventDefault(); + event.stopPropagation(); + resetDragState(); + if (isBusy || submitInFlightRef.current) return; + + const dropped = resolveDroppedProjectFolder(event.dataTransfer); + if ("error" in dropped) { + setError(dropped.error); + return; + } + + setQuery(dropped.path); + void runSubmission(async () => { + if (dropped.requiresDirectoryValidation) { + const api = readNativeApi(); + if (!api) throw new Error("The app server is unavailable."); + try { + await api.filesystem.browse({ partialPath: getInitialBrowseQuery(dropped.path) }); + } catch { + throw new Error("Drop an accessible folder, not a file."); + } + } + await onSubmit(dropped.path, { createIfMissing: false }); + }); + }; + const handleOutsideDragOver = (event: globalThis.DragEvent) => { + if ( + !event.dataTransfer || + !isProjectFolderDrag(event.dataTransfer.types) || + event.composedPath().includes(dropTarget) + ) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "none"; + resetDragState(); + }; + const handleOutsideDrop = (event: globalThis.DragEvent) => { + if ( + !event.dataTransfer || + !isProjectFolderDrag(event.dataTransfer.types) || + event.composedPath().includes(dropTarget) + ) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "none"; + resetDragState(); + }; + + ownerDocument.addEventListener("dragover", handleOutsideDragOver, true); + ownerDocument.addEventListener("drop", handleOutsideDrop, true); + dropTarget.addEventListener("dragenter", handleDragEnter, true); + dropTarget.addEventListener("dragover", handleDragOver, true); + dropTarget.addEventListener("dragleave", handleDragLeave, true); + dropTarget.addEventListener("drop", handleDrop, true); + return () => { + ownerDocument.removeEventListener("dragover", handleOutsideDragOver, true); + ownerDocument.removeEventListener("drop", handleOutsideDrop, true); + dropTarget.removeEventListener("dragenter", handleDragEnter, true); + dropTarget.removeEventListener("dragover", handleDragOver, true); + dropTarget.removeEventListener("dragleave", handleDragLeave, true); + dropTarget.removeEventListener("drop", handleDrop, true); + dragDepth = 0; + }; + }, [isBusy, onSubmit, props.folderDropTarget, runSubmission, supportsFolderDrop]); + return ( setHighlightedValue(typeof value === "string" ? value : null)} > - +
- + {supportsFolderDrop ? ( +
+ + {isFolderDragActive ? ( + Release to add this folder + ) : ( + + Drop your folder here + or browse below + + )} +
+ ) : null} + {props.cloneDirectoryName ? "Select where to clone" : "Directories"} @@ -392,15 +580,18 @@ function ProjectPathBrowser(props: PathBrowserProps & { homeDir: string | null } {!isFetching && filteredEntries.length === 0 && !canBrowseUp ? ( No matching folders. ) : null} - {error || browseError ? ( -
- {error ?? - (browseError instanceof Error - ? browseError.message - : "Unable to browse this folder.")} -
- ) : null}
+ {error || browseError ? ( +
+ {error ?? + (browseError instanceof Error + ? browseError.message + : "Unable to browse this folder.")} +
+ ) : null}
{ - if (submitInFlightRef.current) return; - submitInFlightRef.current = true; - try { - const api = readNativeApi(); - if (!api) throw new Error("The app server is unavailable."); - const picked = await api.dialogs.pickFolder(); - if (!picked) return; - const target = props.cloneDirectoryName - ? joinProjectPath(picked, props.cloneDirectoryName) - : picked; - await props.onSubmit(target, { createIfMissing: false }); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Unable to add project."); - } finally { - submitInFlightRef.current = false; - } - }} + onClick={pickAndSubmitFolder} > {fileManagerLabel(platform)} @@ -469,6 +643,7 @@ export function AddProjectDialog(props: AddProjectDialogProps) { const [error, setError] = useState(null); const [isWorking, setIsWorking] = useState(false); const [highlightedSource, setHighlightedSource] = useState(null); + const [dialogPopupElement, setDialogPopupElement] = useState(null); const openRef = useRef(props.open); const operationGenerationRef = useRef(0); openRef.current = props.open; @@ -598,7 +773,12 @@ export function AddProjectDialog(props: AddProjectDialogProps) { return ( - + {step === "local" ? ( { const operationGeneration = beginOperation(); diff --git a/design-qa.md b/design-qa.md index 408e4e59..eb07c1a2 100644 --- a/design-qa.md +++ b/design-qa.md @@ -4,6 +4,8 @@ Project-source intake adapted to Scient's existing command-dialog primitives and project-initialization flow. The intentionally supported sources are Local folder, Git URL, GitHub repository, and GitLab repository. +This follow-up adds a compact folder-drop affordance to the existing Local folder browser. It does not replace the dialog, hide the directory list, or change Scient's project-initialization policy. + ## Visual evidence - Reference source chooser: `docs/pr-screenshots/add-project-dialog/reference-source-chooser.png` @@ -12,9 +14,18 @@ Project-source intake adapted to Scient's existing command-dialog primitives and - Implemented source chooser: `docs/pr-screenshots/add-project-dialog/after-source-chooser.png` - Implemented path browser: `docs/pr-screenshots/add-project-dialog/after-path-browser.png` - Implemented persistent sidebar action: `docs/pr-screenshots/add-project-dialog/after-sidebar.png` +- User-approved resting row crop: `docs/pr-screenshots/add-project-dialog/folder-drop-resting-reference-row.png` +- User-approved drag-over row crop: `docs/pr-screenshots/add-project-dialog/folder-drop-active-reference-row.png` +- Native Scient resting row: `docs/pr-screenshots/add-project-dialog/folder-drop-resting-native-row.png` +- Sanitized component-harness resting state: `docs/pr-screenshots/add-project-dialog/folder-drop-resting-sanitized.png` +- Sanitized component-harness drag-over state: `docs/pr-screenshots/add-project-dialog/folder-drop-active-sanitized.png` +- Resting row comparison: `docs/pr-screenshots/add-project-dialog/folder-drop-resting-row-comparison.png` +- Drag-over row comparison: `docs/pr-screenshots/add-project-dialog/folder-drop-active-row-comparison.png` The reference and implementation were compared together twice. Final measurements at the 1011 x 654 CSS-pixel viewport were 576 x 352 pixels for the source chooser and 576 x 420 pixels for the path browser. A 600 x 480 compact viewport was also checked with no horizontal overflow. +The new row was compared in both resting and drag-over states. The implementation row is 48 CSS pixels tall and its 36-pixel icon tile is vertically centered within one pixel. The native Electron capture confirms the resting row in the real Scient dialog. The sanitized component harness supplies the active-state comparison without recording local project names or paths; the browser harness darkens its whole modal surface, so color judgment for that state is based on the isolated user-approved row plus computed component colors, while behavior and geometry are proved by browser tests. + ## Findings resolved - Reduced the initial 672-pixel dialog to the reference's 576-pixel width. @@ -23,6 +34,10 @@ The reference and implementation were compared together twice. Final measurement - Removed the command input's default search icon where it overlapped the back arrow. - Preserved Enter navigation for the highlighted parent-directory row while retaining modifier-Enter submission for a highlighted folder. - Kept unsupported Azure DevOps and Bitbucket rows out of the production flow instead of exposing non-functional options. +- Kept the drop row outside the scrollable listbox, so folder names remain visible and listbox semantics stay valid. +- Kept the row white and compact at rest, with only the small folder-plus tile using Scient's blue accent. +- Changed only the tile and copy to green during a valid drag-over; the directory browser remains visible underneath. +- Limited the advertised drop affordance to Electron surfaces that expose the native path resolver, avoiding a dead control in ordinary browsers. ## Functional evidence @@ -33,16 +48,47 @@ The reference and implementation were compared together twice. Final measurement - A live local folder was added through the complete dialog-to-project-initialization journey. - Clone failures remove only the newly reserved destination and do not create or delete unrelated parent folders. - The final browser state has no current application errors; historical connection warnings occurred only while the isolated development server was intentionally stopped. +- The browser harness proves that any part of the open Local folder popup card captures a compatible single-folder drag, sets copy feedback, and changes the row copy to “Release to add this folder.” File drops that miss the card are neutrally prevented on the backdrop so Chromium cannot navigate away. +- When Electron supplies that compatible payload, the handler resolves the item through the native bridge and routes its absolute path through the same existing submission path as browse/manual selection. +- File drops, multiple-item drops, unreadable native paths, and terminal-whitespace paths fail closed with focused guidance. +- A synchronous single-flight guard prevents rapid duplicate drops or a simultaneous picker action from submitting the same project twice. +- The row remains visible after scrolling the directory list to the bottom, and its polite status announcement is outside the listbox. + +## Upstream lineage and disposition + +- Donor concept: Synara commit `3eb5b1088f3189513115e389bf3b19eaffc7e821`, `apps/web/src/components/CreateProjectDialog.tsx`. +- Disposition: **Adapt / Reimplement** the single-folder drag-and-drop concept on Scient's existing Local folder seam. +- Not imported: Synara's broader Spaces model, replacement dialog architecture, provider/runtime changes, or project lifecycle assumptions. +- Synara's full unreviewed range was not advanced by this bounded adaptation. `reviewedThrough` remains `3a5720bdd0ae4ace444379cabf0a634941d232fd`. ## Verification -- Full repository test suite: 12/12 tasks passed. +- Exact current-main integration tested product head: `ce42a2f90d97f2603c16c81a3ea8bfed76d97348` on base `ca615ee765e29c46a973ef3dd93543d1cccf7cb5`. +- The final evidence-only candidate and its complete repository/hosted verification are recorded in PR #115; this file does not attempt to self-reference the commit that contains it. +- Focused verification on the exact product head: folder-drop logic 11/11, Add Project Chromium behavior 22/22, web typecheck, and `git diff --check` passed. +- Full repository test suite on `9cf26b2b462610335988c2659c25e253a0386f84`: 12/12 tasks passed; the server suite reported 2207 passed and 7 skipped. +- Historical current-main integration `5bc3a84e726abc5327fd25a2d2562ac17bd0dd0a` on base `d399509d1835882c4e13b2b01820e649f31adbfd`: 12/12 tasks passed. +- Historical current-main integration `9dd30f221328510781bc84189e10ea2661138b6f` on base `4144022c2f1d127583b9a3a9a3ddfda635ffb10d`: 12/12 tasks passed. +- Full repository test suite on the previously certified feature head `e118d0a33ec65890f79a490d56bf1134e8b851e0`: 12/12 tasks passed. +- Historical context: the full suite on earlier post-merge feature head `3a8d5616cdc984609cc41dc0bd23552fb363f1ca` passed 11/12 tasks. The sole failure was `apps/server/src/localServerMonitor.platform.test.ts`, whose native macOS listener discovery returned no processes. The same isolated test failed identically on exact then-current `origin/main` `fffd9e7797b8df34d19ac2bb4439740fb29f11f6`; neither that test nor its implementation differed in the feature branch. That test passed in the exact `9dd30f221328510781bc84189e10ea2661138b6f` full-suite run. - Server project-source tests: 10/10 passed. -- Web focused unit tests: 32/32 passed. -- Add-project browser tests: 3/3 passed. +- Folder-drop logic tests: 11/11 passed. +- Add-project browser tests: 22/22 passed. +- Stable browser certification on `9cf26b2b462610335988c2659c25e253a0386f84`: 336 passed, 19 skipped across all four isolated groups. +- Geometry validation on `9cf26b2b462610335988c2659c25e253a0386f84`: 19 passed, 336 skipped. - Repository typecheck: 9/9 packages passed. - Repository lint: 0 errors (existing warnings remain outside this feature). -- Production web build, server bundle, and full serialized repository build passed. +- Brand and identity check passed. +- Production web build, desktop bundle, server bundle, and CLI bundle passed (5/5 tasks). +- Release smoke passed, including a native `node-pty` spawn. +- Native Electron browser-overlay lifecycle acceptance passed after the current-main merge, including 31.25 seconds held occluded and successful recovery. +- Current-main universal local-file viewer commit `0428e693417b031529befc2b7498a2635011becb` merged without overlap in the folder-drop implementation or its focused tests. +- The folder-plus tile opened the native macOS directory picker from the exact isolated build and used the same picker/submission action as the footer control. +- User visual review approved the row, icon, hierarchy, and drag-active presentation. +- Real Finder-to-Electron drag was explicitly waived by Yaacov as a residual acceptance limitation, + not a merge gate. Automated cross-window pointer drags did not produce renderer drag events, so + this record does not claim native drop proof. +- Sanitized evidence scan found no local user names, private paths, or embedded image text. - `git diff --check` passed. -final result: passed +final result: affected feature and intake gates passed on the current-main integration; the earlier platform-listener failure is retained only as historical context, and literal Finder drag delivery remains an explicitly accepted, unperformed limitation diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-active-reference-row.png b/docs/pr-screenshots/add-project-dialog/folder-drop-active-reference-row.png new file mode 100644 index 00000000..ef307e48 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-active-reference-row.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-active-row-comparison.png b/docs/pr-screenshots/add-project-dialog/folder-drop-active-row-comparison.png new file mode 100644 index 00000000..54d91ca4 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-active-row-comparison.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-active-sanitized.png b/docs/pr-screenshots/add-project-dialog/folder-drop-active-sanitized.png new file mode 100644 index 00000000..dec913b0 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-active-sanitized.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-resting-native-row.png b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-native-row.png new file mode 100644 index 00000000..f135e71f Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-native-row.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-resting-reference-row.png b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-reference-row.png new file mode 100644 index 00000000..00e19bbb Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-reference-row.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-resting-row-comparison.png b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-row-comparison.png new file mode 100644 index 00000000..89edbd22 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-row-comparison.png differ diff --git a/docs/pr-screenshots/add-project-dialog/folder-drop-resting-sanitized.png b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-sanitized.png new file mode 100644 index 00000000..58340dc3 Binary files /dev/null and b/docs/pr-screenshots/add-project-dialog/folder-drop-resting-sanitized.png differ