Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 13 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) =>
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/commandPaletteBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 10 additions & 5 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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" };

Expand All @@ -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":
Expand Down
147 changes: 109 additions & 38 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<void> => {
const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId);
async (environmentId: EnvironmentId, prefilledPath?: string): Promise<void> => {
const initialQuery = prefilledPath ?? getAddProjectInitialQueryForEnvironment(environmentId);
const initialBrowsePath = getBrowseDirectoryPath(initialQuery);
const browseCwd = getBrowseCwdForEnvironment(environmentId);
const view: CommandPaletteView = {
Expand Down Expand Up @@ -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<void> => {
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],
);
Comment thread
cursor[bot] marked this conversation as resolved.

useLayoutEffect(() => {
if (openIntent?.kind !== "add-project") {
return;
}
clearOpenIntent();
if (openIntent.path) {
void startAddProjectAtPath(openIntent.path);
return;
Comment thread
cursor[bot] marked this conversation as resolved.
}
openAddProjectFlow();
}, [clearOpenIntent, openAddProjectFlow, openIntent]);
}, [clearOpenIntent, openAddProjectFlow, openIntent, startAddProjectAtPath]);

useLayoutEffect(() => {
if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) {
Expand Down Expand Up @@ -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({
Expand All @@ -2277,13 +2349,12 @@ function OpenCommandPaletteDialog(props: {
browseEnvironmentId,
browseEnvironmentPlatform,
canOpenProjectFromFileManager,
desktopLocalBootstraps,
environments,
fileManagerInitialPath,
handleAddProject,
handleAddProjectForEnvironment,
isPickingProjectFolder,
primaryEnvironmentId,
resolveWslProjectTarget,
]);

const inputAccessory =
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3382,7 +3417,14 @@ export default function Sidebar() {
<>
<SidebarChromeHeader isElectron={isElectron} />
<SidebarContent
className="gap-0"
// min-h-full keeps the folder drop target the full height of the list
// area, so an empty sidebar is still a target worth aiming at.
className={cn(
"min-h-full gap-0",
isProjectFolderDragActive &&
"rounded-lg bg-primary/[0.04] outline-2 outline-dashed -outline-offset-4 outline-primary/50",
)}
{...(canDropProjectFolders ? projectFolderDropHandlers : {})}
fixedHeader={
// Lifted above the stage backdrop, whose fade bleeds below the
// header and would otherwise paint across the search row's outline.
Expand Down Expand Up @@ -3921,6 +3963,7 @@ export default function Sidebar() {
<PlusIcon className="-mx-0.5 size-3" />
Add project
</button>
{canDropProjectFolders ? <span>or drop a folder here</span> : null}
</>
) : scopedProjectGroup ? (
`No threads in ${scopedProjectGroup.displayName} yet`
Expand Down
Loading
Loading