diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b56be717e201..804516508829 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,11 +1,12 @@ import type { DesktopBridge, + DroppedFileHandle, DesktopPreviewPointerEvent, DesktopPreviewRecordingFrame, DesktopPreviewTabState, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -101,6 +102,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + getPathForDroppedFile: (file: DroppedFileHandle) => { + try { + // The bridge contract names a structural handle because contracts is + // built without DOM types; every caller passes a real dropped `File`. + const path = webUtils.getPathForFile(file as File); + return path.length > 0 ? path : null; + } catch { + // A file that no longer belongs to a live drop has no path to report. + return null; + } + }, pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992c..241b08d19080 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -4,6 +4,11 @@ const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { readonly open?: "add-project" | "new-thread-in"; + /** + * Prefills the add project path so the palette opens on a confirmation of + * that folder. Ignored by the other intents. + */ + readonly path?: string; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 9bae9c58a977..2ecd0ff9bd51 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -93,6 +93,19 @@ describe("reduceCommandPaletteUiState", () => { }); }); + it("carries a dropped folder path on the add project intent", () => { + expect( + reduceCommandPaletteUiState(closedState, { + _tag: "OpenAddProject", + path: "/repos/api", + }), + ).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "add-project", path: "/repos/api" }, + }); + }); + it("resets to command mode for dialog-driven opens and closes", () => { const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleMode", diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ed758830f4a1..0328ad7b77c6 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -37,9 +37,10 @@ export function browseInputEndPaddingClass(input: { */ export type SearchOverlayMode = "command" | "files" | "content"; -export interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +/** An add-project `path` prefills the surface with a folder to confirm. */ +export type CommandPaletteOpenIntent = + | { readonly kind: "add-project"; readonly path?: string } + | { readonly kind: "new-thread-in" }; export interface CommandPaletteUiState { readonly open: boolean; @@ -50,7 +51,7 @@ export interface CommandPaletteUiState { export type CommandPaletteUiAction = | { readonly _tag: "SetOpen"; readonly open: boolean } | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } - | { readonly _tag: "OpenAddProject" } + | { readonly _tag: "OpenAddProject"; readonly path?: string } | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "ClearOpenIntent" }; @@ -70,7 +71,11 @@ export function reduceCommandPaletteUiState( ? { open: false, mode: "command", openIntent: null } : { open: true, mode: action.mode, openIntent: null }; case "OpenAddProject": - return { open: true, mode: "command", openIntent: { kind: "add-project" } }; + return { + open: true, + mode: "command", + openIntent: { kind: "add-project", ...(action.path ? { path: action.path } : {}) }, + }; case "OpenNewThreadIn": return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; case "ClearOpenIntent": diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 410be73b420a..00d88edaf34a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -398,7 +398,10 @@ export function CommandPalette({ children }: { children: ReactNode }) { (mode: SearchOverlayMode) => dispatch({ _tag: "ToggleMode", mode }), [], ); - const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); + const openAddProject = useCallback( + (path?: string) => dispatch({ _tag: "OpenAddProject", ...(path ? { path } : {}) }), + [], + ); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -473,7 +476,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { if (detail.open === "new-thread-in") { openNewThreadIn(); } else if (detail.open === "add-project") { - openAddProject(); + openAddProject(detail.path); } else { setOpen(true); } @@ -1151,9 +1154,13 @@ function OpenCommandPaletteDialog(props: { } } + /** + * `prefilledPath` opens the browser already pointed at a folder, so the user + * confirms that path instead of navigating to it. + */ const startAddProjectBrowse = useCallback( - async (environmentId: EnvironmentId): Promise => { - const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId); + async (environmentId: EnvironmentId, prefilledPath?: string): Promise => { + const initialQuery = prefilledPath ?? getAddProjectInitialQueryForEnvironment(environmentId); const initialBrowsePath = getBrowseDirectoryPath(initialQuery); const browseCwd = getBrowseCwdForEnvironment(environmentId); const view: CommandPaletteView = { @@ -1392,13 +1399,108 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + /** + * A WSL UNC path names a folder inside a Linux backend rather than on the + * Windows host, so it only becomes a project once it is matched to the + * environment running that distro and rewritten as a Linux path. Callers + * that already read the desktop's WSL state pass it in to avoid a second + * bridge round trip. + */ + const resolveWslProjectTarget = useCallback( + async (path: string, knownWslState: DesktopWslState | null) => { + const wslState = + knownWslState ?? (await window.desktopBridge?.getWslState().catch(() => null)) ?? null; + let primaryRunningDistro: string | null = null; + try { + primaryRunningDistro = + window.desktopBridge + ?.getLocalEnvironmentBootstraps() + .find((bootstrap) => bootstrap.id === PRIMARY_LOCAL_ENVIRONMENT_ID)?.runningDistro ?? + null; + } catch { + // Keep UNC routing strict when the live primary identity cannot be read. + } + return resolveWslProjectSelection( + path, + applyWslEnvironmentConfiguration( + environments.flatMap((environment) => { + const backendId = desktopLocalBackendId(environment.entry.target); + if (!backendId) { + return []; + } + + const bootstrap = desktopLocalBootstraps.find( + (candidate) => candidate.httpBaseUrl === environment.displayUrl, + ); + const runningDistro = bootstrap?.runningDistro ?? null; + return [{ environmentId: environment.environmentId, backendId, runningDistro }]; + }), + primaryEnvironmentId, + wslState, + primaryRunningDistro, + ), + ); + }, + [desktopLocalBootstraps, environments, primaryEnvironmentId], + ); + + /** + * A dropped folder skips the environment and source pickers and goes straight + * to confirming the path. It normally belongs to the device hosting this + * window, except for a WSL UNC path, which belongs to whichever backend runs + * the distro it names. + */ + const startAddProjectAtPath = useCallback( + async (path: string): Promise => { + if (parseWslUncPath(path)) { + const selection = await resolveWslProjectTarget(path, null); + if (!selection) { + setOpen(false); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not add WSL project", + description: "Start the matching WSL backend, then drop the folder again.", + }), + ); + return; + } + void startAddProjectBrowse(selection.environmentId, selection.linuxPath); + return; + } + const environment = environments.find( + (candidate) => candidate.environmentId === primaryEnvironmentId, + ); + if (!primaryEnvironmentId || !canCreateProjectInEnvironment(environment?.connection.phase)) { + // The drop opened the palette only to reach this surface, so a failure + // leaves the user back on the sidebar with the error, not on an empty + // palette they never asked for. + setOpen(false); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${environment?.label ?? "This device"} is not connected.`, + }), + ); + return; + } + void startAddProjectBrowse(primaryEnvironmentId, path); + }, + [environments, primaryEnvironmentId, resolveWslProjectTarget, setOpen, startAddProjectBrowse], + ); + useLayoutEffect(() => { if (openIntent?.kind !== "add-project") { return; } clearOpenIntent(); + if (openIntent.path) { + void startAddProjectAtPath(openIntent.path); + return; + } openAddProjectFlow(); - }, [clearOpenIntent, openAddProjectFlow, openIntent]); + }, [clearOpenIntent, openAddProjectFlow, openIntent, startAddProjectAtPath]); useLayoutEffect(() => { if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) { @@ -2222,37 +2324,7 @@ function OpenCommandPaletteDialog(props: { return; } if (parseWslUncPath(pickedPath)) { - desktopWslState ??= (await window.desktopBridge?.getWslState().catch(() => null)) ?? null; - let primaryRunningDistro: string | null = null; - try { - primaryRunningDistro = - window.desktopBridge - ?.getLocalEnvironmentBootstraps() - .find((bootstrap) => bootstrap.id === PRIMARY_LOCAL_ENVIRONMENT_ID)?.runningDistro ?? - null; - } catch { - // Keep UNC routing strict when the live primary identity cannot be read. - } - const selection = resolveWslProjectSelection( - pickedPath, - applyWslEnvironmentConfiguration( - environments.flatMap((environment) => { - const backendId = desktopLocalBackendId(environment.entry.target); - if (!backendId) { - return []; - } - - const bootstrap = desktopLocalBootstraps.find( - (candidate) => candidate.httpBaseUrl === environment.displayUrl, - ); - const runningDistro = bootstrap?.runningDistro ?? null; - return [{ environmentId: environment.environmentId, backendId, runningDistro }]; - }), - primaryEnvironmentId, - desktopWslState ?? null, - primaryRunningDistro, - ), - ); + const selection = await resolveWslProjectTarget(pickedPath, desktopWslState); if (!selection) { toastManager.add( stackedThreadToast({ @@ -2277,13 +2349,12 @@ function OpenCommandPaletteDialog(props: { browseEnvironmentId, browseEnvironmentPlatform, canOpenProjectFromFileManager, - desktopLocalBootstraps, - environments, fileManagerInitialPath, handleAddProject, handleAddProjectForEnvironment, isPickingProjectFolder, primaryEnvironmentId, + resolveWslProjectTarget, ]); const inputAccessory = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2716f2121964..0f68fb21a1a0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -178,6 +178,7 @@ import { Input } from "./ui/input"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { makeProjectFolderDropHandlers } from "./sidebar/projectFolderDrop"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { @@ -1796,6 +1797,40 @@ export default function Sidebar() { () => openCommandPalette({ open: "add-project" }), [], ); + const [isProjectFolderDragActive, setIsProjectFolderDragActive] = useState(false); + // Only the desktop shell can turn a dropped folder into a path, so browser + // clients keep the platform's own drag behavior rather than lighting up a + // target that could never resolve one. + const canDropProjectFolders = + typeof window !== "undefined" && window.desktopBridge?.getPathForDroppedFile !== undefined; + useEffect(() => { + if (!isProjectFolderDragActive) return; + const clearProjectFolderDrag = () => setIsProjectFolderDragActive(false); + window.addEventListener("dragend", clearProjectFolderDrag); + return () => window.removeEventListener("dragend", clearProjectFolderDrag); + }, [isProjectFolderDragActive]); + const projectFolderDropHandlers = useMemo( + () => + makeProjectFolderDropHandlers({ + setDragActive: setIsProjectFolderDragActive, + resolveDroppedFolderPath: (file) => + window.desktopBridge?.getPathForDroppedFile?.(file) ?? null, + addProjectAtPath: (path) => openCommandPalette({ open: "add-project", path }), + rejectDrop: (reason) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: reason === "no-folder" ? "Drop a folder" : "Could not read that folder", + description: + reason === "no-folder" + ? "A project starts from a folder, not a file." + : "Use Add project to pick it instead.", + }), + ); + }, + }), + [], + ); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); @@ -3382,7 +3417,14 @@ export default function Sidebar() { <> Add project + {canDropProjectFolders ? or drop a folder here : null} ) : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` diff --git a/apps/web/src/components/sidebar/projectFolderDrop.test.ts b/apps/web/src/components/sidebar/projectFolderDrop.test.ts new file mode 100644 index 000000000000..d8a888317c26 --- /dev/null +++ b/apps/web/src/components/sidebar/projectFolderDrop.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + makeProjectFolderDropHandlers, + type ProjectFolderDragEvent, + type ProjectFolderDropHost, + type ProjectFolderDropItem, +} from "./projectFolderDrop"; + +function makeItem(options: { name: string; isDirectory: boolean; asFile?: boolean }) { + return { + webkitGetAsEntry: () => ({ isDirectory: options.isDirectory }), + getAsFile: () => + options.asFile === false ? null : { name: options.name, size: options.isDirectory ? 0 : 12 }, + } satisfies ProjectFolderDropItem; +} + +function makeDragEvent(options?: { + types?: string[]; + items?: ProjectFolderDropItem[]; + movedWithinTarget?: boolean; +}) { + const preventDefault = vi.fn(); + const event = { + dataTransfer: { + types: options?.types ?? ["Files"], + items: options?.items ?? [], + dropEffect: "none", + }, + relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, + currentTarget: { + contains: () => options?.movedWithinTarget ?? false, + }, + preventDefault, + } satisfies ProjectFolderDragEvent; + return { event, preventDefault }; +} + +function makeHost(options?: { resolvedPath?: string | null }) { + const setDragActive = vi.fn(); + const addProjectAtPath = vi.fn(); + const rejectDrop = vi.fn(); + const resolvedPath = options && "resolvedPath" in options ? options.resolvedPath : "/repos/api"; + const resolveDroppedFolderPath = vi.fn(() => resolvedPath ?? null); + const host = { + setDragActive, + addProjectAtPath, + rejectDrop, + resolveDroppedFolderPath, + } satisfies ProjectFolderDropHost; + return { host, setDragActive, addProjectAtPath, rejectDrop, resolveDroppedFolderPath }; +} + +describe("makeProjectFolderDropHandlers", () => { + it("activates the target for an external file drag", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent(); + + makeProjectFolderDropHandlers(host).onDragEnter(event); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(setDragActive).toHaveBeenCalledWith(true); + }); + + it("ignores drags that carry no files, such as sidebar thread reordering", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent({ types: ["text/plain"] }); + + makeProjectFolderDropHandlers(host).onDragOver(event); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("does not flicker when the drag moves between children", () => { + const { host, setDragActive } = makeHost(); + const { event } = makeDragEvent({ movedWithinTarget: true }); + + const handlers = makeProjectFolderDropHandlers(host); + handlers.onDragEnter(event); + handlers.onDragLeave(event); + + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("adds the first dropped folder and clears the active state", () => { + const { host, setDragActive, addProjectAtPath, rejectDrop } = makeHost(); + const { event } = makeDragEvent({ + items: [ + makeItem({ name: "notes.md", isDirectory: false }), + makeItem({ name: "api", isDirectory: true }), + makeItem({ name: "web", isDirectory: true }), + ], + }); + + makeProjectFolderDropHandlers(host).onDrop(event); + + expect(setDragActive).toHaveBeenCalledWith(false); + expect(addProjectAtPath).toHaveBeenCalledWith("/repos/api"); + expect(rejectDrop).not.toHaveBeenCalled(); + }); + + it("rejects a drop that carries only files", () => { + const { host, addProjectAtPath, rejectDrop, resolveDroppedFolderPath } = makeHost(); + const { event } = makeDragEvent({ + items: [makeItem({ name: "notes.md", isDirectory: false })], + }); + + makeProjectFolderDropHandlers(host).onDrop(event); + + expect(rejectDrop).toHaveBeenCalledWith("no-folder"); + expect(resolveDroppedFolderPath).not.toHaveBeenCalled(); + expect(addProjectAtPath).not.toHaveBeenCalled(); + }); + + it("rejects a folder whose path cannot be resolved", () => { + const { host, addProjectAtPath, rejectDrop } = makeHost({ resolvedPath: null }); + const { event } = makeDragEvent({ items: [makeItem({ name: "api", isDirectory: true })] }); + + makeProjectFolderDropHandlers(host).onDrop(event); + + expect(rejectDrop).toHaveBeenCalledWith("path-unresolved"); + expect(addProjectAtPath).not.toHaveBeenCalled(); + }); + + it("skips a folder entry that no longer exposes a file", () => { + const { host, addProjectAtPath, rejectDrop } = makeHost(); + const { event } = makeDragEvent({ + items: [makeItem({ name: "api", isDirectory: true, asFile: false })], + }); + + makeProjectFolderDropHandlers(host).onDrop(event); + + expect(rejectDrop).toHaveBeenCalledWith("no-folder"); + expect(addProjectAtPath).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/sidebar/projectFolderDrop.ts b/apps/web/src/components/sidebar/projectFolderDrop.ts new file mode 100644 index 000000000000..8d2c539b1c89 --- /dev/null +++ b/apps/web/src/components/sidebar/projectFolderDrop.ts @@ -0,0 +1,105 @@ +/** + * Dropping a folder onto the sidebar adds it as a project. Only the desktop + * shell can turn a dropped folder into an absolute path, so the host decides + * whether resolution is possible and what to do with the result. + */ +export interface ProjectFolderDropItem { + webkitGetAsEntry(): { readonly isDirectory: boolean } | null; + getAsFile(): { readonly name: string; readonly size: number } | null; +} + +export interface ProjectFolderDragEvent { + readonly dataTransfer: { + readonly types: ReadonlyArray; + readonly items: ArrayLike; + dropEffect: string; + }; + readonly relatedTarget: EventTarget | null; + readonly currentTarget: { + contains(target: Node | null): boolean; + }; + preventDefault(): void; +} + +/** + * `no-folder`: the drop carried files but no directory. + * `path-unresolved`: a directory was dropped but its path came back empty. + */ +export type ProjectFolderDropRejection = "no-folder" | "path-unresolved"; + +export interface ProjectFolderDropHost { + setDragActive(active: boolean): void; + resolveDroppedFolderPath(file: { readonly name: string; readonly size: number }): string | null; + addProjectAtPath(path: string): void; + rejectDrop(reason: ProjectFolderDropRejection): void; +} + +function isFileDrag(event: ProjectFolderDragEvent): boolean { + return event.dataTransfer.types.includes("Files"); +} + +function movedWithinDropTarget(event: ProjectFolderDragEvent): boolean { + return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); +} + +/** + * The first dropped directory, or null. Directories cannot be told apart from + * files until the drop lands, so this runs there rather than on drag over. + */ +function findDroppedFolder( + items: ArrayLike, +): { readonly name: string; readonly size: number } | null { + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + if (!item || item.webkitGetAsEntry()?.isDirectory !== true) continue; + const file = item.getAsFile(); + if (file) return file; + } + return null; +} + +/** + * Handlers for the sidebar's project area. Wire them only when the host can + * resolve dropped paths: a highlighted drop target that can never succeed + * reads as a bug. + */ +export function makeProjectFolderDropHandlers(host: ProjectFolderDropHost) { + return { + onDragEnter(event: ProjectFolderDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(true); + }, + onDragOver(event: ProjectFolderDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + host.setDragActive(true); + }, + onDragLeave(event: ProjectFolderDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(false); + }, + // Several folders at once still resolve to one project, because the add + // project surface confirms a single path. + onDrop(event: ProjectFolderDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + host.setDragActive(false); + const folder = findDroppedFolder(event.dataTransfer.items); + if (!folder) { + host.rejectDrop("no-folder"); + return; + } + const path = host.resolveDroppedFolderPath(folder); + if (!path) { + host.rejectDrop("path-unresolved"); + return; + } + host.addProjectAtPath(path); + }, + }; +} diff --git a/docs/fork/0008-drop-a-folder-to-add-a-project.md b/docs/fork/0008-drop-a-folder-to-add-a-project.md new file mode 100644 index 000000000000..f9eb2a40d7e3 --- /dev/null +++ b/docs/fork/0008-drop-a-folder-to-add-a-project.md @@ -0,0 +1,41 @@ +# 0008: Drop a folder on the sidebar to add a project + +- PR: [TrogonStack/t3code#17](https://github.com/TrogonStack/t3code/pull/17) +- Status: active + +## What you can do now + +- Drag a folder from your file manager onto the desktop app's sidebar to add it + as a project. The add-project surface opens with that folder filled in, so + the last step is confirming it rather than typing or browsing to it. +- See that the sidebar accepts folders: an empty sidebar says so next to its + Add project button, and the list outlines itself while a folder is over it. +- Drop something that cannot become a project, such as a file, and get told + why instead of nothing happening. + +## Why + +Adding the first project is the one thing every new install has to do, and +until now it took a command palette, an environment, a source, and a typed +path. Dragging the folder in is how every other app on the machine takes a +directory, and it is what people try first: the empty sidebar looks like a drop +target whether or not it is one. + +Dropping a folder is also the only add-project path that needs no knowledge of +the app's vocabulary, which matters most exactly when someone has just +installed it and has nothing to compare against. + +## Upstream considerations + +Nothing here is fork-specific and it touches upstream files on every surface it +needs (the bridge contract, the desktop preload, the sidebar, the command +palette), so it belongs upstream as a feature rather than something to carry. +Submit it and delete this entry once it merges. + +While it is carried, the sidebar and command palette edits are the parts a +sync will notice, since both files move often upstream. The rest is additive: +one optional bridge method and one self-contained drop helper. + +Mobile is deliberately untouched: the platform has no file manager to drag from. +Browser clients are untouched for a harder reason, that the web platform never +exposes a dropped folder's path, so only the desktop shell can resolve one. diff --git a/docs/fork/README.md b/docs/fork/README.md index 1f674857501c..6de813b11c7d 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -32,3 +32,4 @@ Each entry uses these sections: | 0003 | [Native subagent threads for Claude orchestrators](./0003-native-subagent-threads.md) | [#3](https://github.com/TrogonStack/t3code/pull/3) | active | | 0006 | [Fork schema on its own migration ledger](./0006-fork-migration-ledger.md) | [#13](https://github.com/TrogonStack/t3code/pull/13), [#16](https://github.com/TrogonStack/t3code/pull/16) | active | | 0007 | [API-key Codex installs are not reported as broken](./0007-codex-api-key-auth-is-supported.md) | [#15](https://github.com/TrogonStack/t3code/pull/15) | active | +| 0008 | [Drop a folder on the sidebar to add a project](./0008-drop-a-folder-to-add-a-project.md) | [#17](https://github.com/TrogonStack/t3code/pull/17) | active | diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 70b3cccc962a..2f2e68589b86 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -12,6 +12,18 @@ If reordering is unavailable for one environment, update the T3 Code server runn environment. Older servers can still pin and unpin threads, but do not understand synced ordering; their pinned threads keep the default newest-first order below the ones you have arranged. +## Add a project by dropping a folder + +In the desktop app, drag a folder from your file manager onto the sidebar. T3 Code opens **Add +project** with that folder already filled in, so you confirm it with **Add** or Enter. An empty +sidebar says so next to its **Add project** button. + +The folder is added to this device's environment, the one the desktop app runs itself. To add a +folder that lives on another machine, use **Add project** and browse that environment instead. + +Browsers do not tell an app where a dropped folder lives on disk, so the sidebar in a browser tab +is not a drop target. Use **Add project** there. + ## Environment artwork Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 9be21da65b04..76fb8d14120f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -452,6 +452,16 @@ export interface PickedThemeFile { text: string; } +/** + * Structural stand-in for the DOM `File`, which this package cannot name + * because it is built without DOM types. Only the object's identity matters to + * its one consumer, the desktop dropped-path resolver. + */ +export interface DroppedFileHandle { + readonly name: string; + readonly size: number; +} + export const PickedThemeFileSchema = Schema.Struct({ name: Schema.String, size: Schema.Number, @@ -1086,6 +1096,13 @@ export interface DesktopBridge { setWslDistro: (distro: string | null) => Promise; setWslOnly: (enabled: boolean) => Promise; pickFolder: (options?: PickFolderOptions) => Promise; + /** + * Absolute path of a file or folder the user dropped onto the window. The web + * platform never exposes it, so this is the only way a dropped folder can + * become a project path. Optional: older desktop builds lack it, and browser + * clients have no equivalent at all. + */ + getPathForDroppedFile?: (file: DroppedFileHandle) => string | null; /** * Multi-select JSON file picker that opens in the VS Code extensions * directory when one exists. Optional: older desktop builds lack it, and