From c27da9a6e31b3dd10c59a366e7aef62848ca0e5f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 17 Aug 2026 05:46:38 -0400 Subject: [PATCH 1/3] feat(web): drop a folder on the sidebar to add a project Adding the first project is the one thing every install has to do, and it required knowing the app's vocabulary before anything worked. Dragging the folder in is what people try first, especially against an empty sidebar that already looks like a drop target. Signed-off-by: Yordis Prieto --- apps/desktop/src/preload.ts | 14 +- apps/web/src/commandPaletteBus.ts | 5 + .../components/CommandPalette.logic.test.ts | 13 ++ .../src/components/CommandPalette.logic.ts | 15 +- apps/web/src/components/CommandPalette.tsx | 46 +++++- apps/web/src/components/Sidebar.tsx | 45 +++++- .../sidebar/projectFolderDrop.test.ts | 136 ++++++++++++++++++ .../components/sidebar/projectFolderDrop.ts | 105 ++++++++++++++ .../0008-drop-a-folder-to-add-a-project.md | 41 ++++++ docs/fork/README.md | 1 + docs/user/thread-sidebar.md | 12 ++ packages/contracts/src/ipc.ts | 17 +++ 12 files changed, 438 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/sidebar/projectFolderDrop.test.ts create mode 100644 apps/web/src/components/sidebar/projectFolderDrop.ts create mode 100644 docs/fork/0008-drop-a-folder-to-add-a-project.md diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b56be717e20..80451650882 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 2a953132992..241b08d1908 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 9bae9c58a97..2ecd0ff9bd5 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 ed758830f4a..0328ad7b77c 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 410be73b420..ef7557bf2ad 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,42 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + /** + * A dropped folder always belongs to the device hosting this window, so it + * skips the environment and source pickers and goes straight to confirming + * the path against the primary environment. + */ + const startAddProjectAtPath = useCallback( + (path: string): void => { + const environment = environments.find( + (candidate) => candidate.environmentId === primaryEnvironmentId, + ); + if (!primaryEnvironmentId || !canCreateProjectInEnvironment(environment?.connection.phase)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${environment?.label ?? "This device"} is not connected.`, + }), + ); + return; + } + void startAddProjectBrowse(primaryEnvironmentId, path); + }, + [environments, primaryEnvironmentId, startAddProjectBrowse], + ); + useLayoutEffect(() => { if (openIntent?.kind !== "add-project") { return; } clearOpenIntent(); + if (openIntent.path) { + startAddProjectAtPath(openIntent.path); + return; + } openAddProjectFlow(); - }, [clearOpenIntent, openAddProjectFlow, openIntent]); + }, [clearOpenIntent, openAddProjectFlow, openIntent, startAddProjectAtPath]); useLayoutEffect(() => { if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2716f212196..0f68fb21a1a 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 00000000000..d8a888317c2 --- /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 00000000000..8d2c539b1c8 --- /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 00000000000..f9eb2a40d7e --- /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 1f674857501..6de813b11c7 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 70b3cccc962..2f2e68589b8 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 9be21da65b0..76fb8d14120 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 From 78575e78f1766c553c0e0c6ff02292ffb03b964c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 17 Aug 2026 05:54:10 -0400 Subject: [PATCH 2/3] fix(web): a failed folder drop leaves the sidebar, not an empty palette The palette only opens to host the add-project surface, so bailing out of it should not strand the user somewhere they never chose to be. Signed-off-by: Yordis Prieto --- apps/web/src/components/CommandPalette.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ef7557bf2ad..dba84a063ce 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1410,6 +1410,10 @@ function OpenCommandPaletteDialog(props: { (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", @@ -1421,7 +1425,7 @@ function OpenCommandPaletteDialog(props: { } void startAddProjectBrowse(primaryEnvironmentId, path); }, - [environments, primaryEnvironmentId, startAddProjectBrowse], + [environments, primaryEnvironmentId, setOpen, startAddProjectBrowse], ); useLayoutEffect(() => { From 2774e0d912c153bb3f21a75b4a12b03fec9ad1ed Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 17 Aug 2026 06:10:36 -0400 Subject: [PATCH 3/3] fix(web): a dropped WSL folder reaches the backend that owns it A UNC path names a folder inside a Linux distro, not on the Windows host, so sending it to the host environment could never produce a working project. The folder picker already knew this; dropping deserves the same answer. Signed-off-by: Yordis Prieto --- apps/web/src/components/CommandPalette.tsx | 109 +++++++++++++-------- 1 file changed, 70 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index dba84a063ce..00d88edaf34 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1400,12 +1400,74 @@ function OpenCommandPaletteDialog(props: { ]); /** - * A dropped folder always belongs to the device hosting this window, so it - * skips the environment and source pickers and goes straight to confirming - * the path against the primary environment. + * 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( - (path: string): void => { + 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, ); @@ -1425,7 +1487,7 @@ function OpenCommandPaletteDialog(props: { } void startAddProjectBrowse(primaryEnvironmentId, path); }, - [environments, primaryEnvironmentId, setOpen, startAddProjectBrowse], + [environments, primaryEnvironmentId, resolveWslProjectTarget, setOpen, startAddProjectBrowse], ); useLayoutEffect(() => { @@ -1434,7 +1496,7 @@ function OpenCommandPaletteDialog(props: { } clearOpenIntent(); if (openIntent.path) { - startAddProjectAtPath(openIntent.path); + void startAddProjectAtPath(openIntent.path); return; } openAddProjectFlow(); @@ -2262,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({ @@ -2317,13 +2349,12 @@ function OpenCommandPaletteDialog(props: { browseEnvironmentId, browseEnvironmentPlatform, canOpenProjectFromFileManager, - desktopLocalBootstraps, - environments, fileManagerInitialPath, handleAddProject, handleAddProjectForEnvironment, isPickingProjectFolder, primaryEnvironmentId, + resolveWslProjectTarget, ]); const inputAccessory =