Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b017d4b
feat(projects): add native folder drop intake
yaacovcorcos Jul 24, 2026
d7a2464
fix(projects): harden folder drop feedback
yaacovcorcos Jul 24, 2026
11ba5d0
fix(projects): keep folder errors visible
yaacovcorcos Jul 24, 2026
a73dec9
fix(projects): preserve compact folder dialog footer
yaacovcorcos Jul 24, 2026
843b1bb
fix(projects): harden native folder intake
yaacovcorcos Jul 24, 2026
44f5047
docs(qa): align folder intake evidence
yaacovcorcos Jul 24, 2026
f0581e2
fix(projects): scope folder drop to dialog
yaacovcorcos Jul 24, 2026
2b5d372
test(projects): cover platform folder labels
yaacovcorcos Jul 24, 2026
e118d0a
Merge origin/main into folder drop intake
yaacovcorcos Jul 24, 2026
3a8d561
Merge remote-tracking branch 'origin/main' into maintenance/synara-fo…
yaacovcorcos Jul 24, 2026
0a55298
docs: correct folder drop verification evidence
yaacovcorcos Jul 24, 2026
a09da60
docs: qualify native drag evidence
yaacovcorcos Jul 24, 2026
9dd30f2
Merge remote-tracking branch 'origin/main' into maintenance/synara-fo…
yaacovcorcos Jul 24, 2026
4f12637
docs: refresh folder drop verification evidence
yaacovcorcos Jul 24, 2026
5bc3a84
Merge remote-tracking branch 'origin/main' into maintenance/synara-fo…
yaacovcorcos Jul 24, 2026
80275bc
docs: refresh folder drop certification evidence
yaacovcorcos Jul 24, 2026
9cf26b2
Merge remote-tracking branch 'origin/main' into maintenance/synara-fo…
yaacovcorcos Jul 24, 2026
ec98e1e
docs: recertify folder drop on latest main
yaacovcorcos Jul 24, 2026
93f511e
Merge remote-tracking branch 'origin/main' into maintenance/synara-fo…
yaacovcorcos Jul 24, 2026
11880f4
Merge remote-tracking branch 'origin/main' into maintenance/synara-fo…
yaacovcorcos Jul 24, 2026
ce42a2f
Neutralize project drops outside dialog
yaacovcorcos Jul 24, 2026
96f2940
Update folder drop acceptance evidence
yaacovcorcos Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
445 changes: 444 additions & 1 deletion apps/web/src/components/AddProjectDialog.browser.tsx

Large diffs are not rendered by default.

161 changes: 160 additions & 1 deletion apps/web/src/components/AddProjectDialog.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/components/AddProjectDialog.logic.ts
Original file line number Diff line number Diff line change
@@ -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<ComposerDroppedFileItem>;
readonly files: Iterable<File>;
}

export function isProjectFolderDrag(types: Iterable<string>): 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>,
): 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()
Expand Down
Loading
Loading