Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/code/src/main/services/context-menu/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const taskContextMenuInput = z.object({
isSuspended: z.boolean().optional(),
isInCommandCenter: z.boolean().optional(),
hasEmptyCommandCenterCell: z.boolean().optional(),
fileToFolders: z
.array(z.object({ id: z.string(), path: z.string() }))
.optional(),
});

export const bulkTaskContextMenuInput = z.object({
Expand Down Expand Up @@ -47,6 +50,7 @@ const taskAction = z.discriminatedUnion("type", [
z.object({ type: z.literal("delete") }),
z.object({ type: z.literal("add-to-command-center") }),
z.object({ type: z.literal("external-app"), action: externalAppAction }),
z.object({ type: z.literal("file-to"), folderPath: z.string() }),
]);

const bulkTaskAction = z.discriminatedUnion("type", [
Expand Down
19 changes: 19 additions & 0 deletions apps/code/src/main/services/context-menu/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,32 @@ export class ContextMenuService {
isSuspended,
isInCommandCenter,
hasEmptyCommandCenterCell,
fileToFolders,
} = input;
const { apps, lastUsedAppId } = await this.getExternalAppsData();
const hasPath = worktreePath || folderPath;
const fileToItems: MenuItemDef<TaskAction>[] =
fileToFolders && fileToFolders.length > 0
? [
this.separator(),
{
type: "submenu",
label: "File to...",
items: fileToFolders.map((folder) => ({
label: folder.path,
action: {
type: "file-to" as const,
folderPath: folder.path,
},
})),
},
]
: [];

return this.showMenu<TaskAction>([
this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }),
this.item("Rename", { type: "rename" }),
...fileToItems,
...(worktreePath
? [
this.separator(),
Expand Down
110 changes: 110 additions & 0 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,116 @@ export class PostHogAPIClient {
return all;
}

// The desktop file system tree lives on its own server-controlled "desktop"
// surface, served from a route that is not in the generated OpenAPI client,
// so we use the raw fetcher and follow pagination manually.
async getDesktopFileSystem(): Promise<Schemas.FileSystem[]> {
const DESKTOP_FILE_SYSTEM_MAX_PAGES = 50;
const teamId = await this.getTeamId();
const all: Schemas.FileSystem[] = [];
let urlPath: string = `/api/projects/${teamId}/desktop_file_system/`;
for (let i = 0; i < DESKTOP_FILE_SYSTEM_MAX_PAGES; i++) {
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) {
throw new Error(
`Failed to fetch desktop file system: ${response.statusText}`,
);
}
const page = (await response.json()) as Schemas.PaginatedFileSystemList;
all.push(...page.results);
if (!page.next) return all;
const nextUrl = new URL(page.next);
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
}
log.warn(
`getDesktopFileSystem hit MAX_PAGES (${DESKTOP_FILE_SYSTEM_MAX_PAGES}); returning partial results`,
{ returned: all.length },
);
return all;
}

// Create a top-level channel (a folder row whose path is a single segment) on
// the desktop file system surface. Uses the raw fetcher for the same reason as
// getDesktopFileSystem: this route is not in the generated OpenAPI client.
async createDesktopFileSystemChannel(
name: string,
): Promise<Schemas.FileSystem> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: urlPath,
overrides: {
body: JSON.stringify({ path: name, type: "folder", depth: 1 }),
},
});
if (!response.ok) {
throw new Error(
`Failed to create desktop file system channel: ${response.statusText}`,
);
}
return (await response.json()) as Schemas.FileSystem;
}

// Create a leaf file system entry (e.g. filing a task under a channel folder)
// on the desktop surface. `path` is slash-delimited and includes the parent
// folder path; `ref` links the entry back to its source domain object.
async createDesktopFileSystemEntry(input: {
path: string;
type: string;
ref?: string;
href?: string;
}): Promise<Schemas.FileSystem> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const depth = input.path.split("/").filter((s) => s.length > 0).length;
const response = await this.api.fetcher.fetch({
method: "post",
url,
path: urlPath,
overrides: {
body: JSON.stringify({
path: input.path,
type: input.type,
depth,
ref: input.ref,
href: input.href,
}),
},
});
if (!response.ok) {
throw new Error(
`Failed to create desktop file system entry: ${response.statusText}`,
);
}
return (await response.json()) as Schemas.FileSystem;
}

// Delete a desktop file system entry by id (used to remove top-level channels).
async deleteDesktopFileSystem(id: string): Promise<void> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(id)}/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "delete",
url,
path: urlPath,
});
if (!response.ok && response.status !== 404) {
throw new Error(
`Failed to delete desktop file system channel: ${response.statusText}`,
);
}
}

async getTask(taskId: string) {
const teamId = await this.getTeamId();
const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Plus } from "@phosphor-icons/react";
import { Flex, IconButton, Text } from "@radix-ui/themes";
import { useState } from "react";
import { CreateChannelModal } from "./CreateChannelModal";

// Header above the channel tree with an "add channel" affordance that opens a
// Slack-style create-channel modal.
export function ChannelsHeader() {
const [isModalOpen, setIsModalOpen] = useState(false);

return (
<Flex direction="column" className="px-2 pb-1">
<Flex align="center" justify="between" className="h-[22px]">
<Text className="font-medium text-[11px] text-gray-10 uppercase tracking-wide">
Channels
</Text>
<IconButton
type="button"
variant="ghost"
color="gray"
size="1"
aria-label="Create channel"
onClick={() => setIsModalOpen(true)}
>
<Plus size={12} />
</IconButton>
</Flex>
<CreateChannelModal open={isModalOpen} onOpenChange={setIsModalOpen} />
</Flex>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Button } from "@components/ui/Button";
import { Hash, X } from "@phosphor-icons/react";
import { Dialog, Flex, IconButton, Text, TextField } from "@radix-ui/themes";
import { useEffect, useState } from "react";
import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem";

// Matches Slack's "Create a channel" naming constraint.
const MAX_CHANNEL_NAME_LENGTH = 80;

interface CreateChannelModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export function CreateChannelModal({
open,
onOpenChange,
}: CreateChannelModalProps) {
const { createChannel, isCreating } = useDesktopFileSystemMutations();
const [name, setName] = useState("");

// Reset the field each time the modal opens so a previous draft never lingers.
useEffect(() => {
if (open) setName("");
}, [open]);

const trimmed = name.trim();
const remaining = MAX_CHANNEL_NAME_LENGTH - name.length;

const submit = async () => {
if (!trimmed) return;
try {
await createChannel(trimmed);
onOpenChange(false);
} catch {
// Keep the modal open so the user can retry; the mutation surfaces the error.
}
};

return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!isCreating) onOpenChange(next);
}}
>
<Dialog.Content maxWidth="560px">
<Flex align="start" justify="between" gap="3">
<Dialog.Title>
<Text className="font-bold text-lg">Create a channel</Text>
</Dialog.Title>
<Dialog.Close>
<IconButton
variant="ghost"
color="gray"
size="2"
aria-label="Close"
disabled={isCreating}
>
<X size={18} />
</IconButton>
</Dialog.Close>
</Flex>

<Flex direction="column" gap="2" mt="4">
<Text
as="label"
htmlFor="channel-name"
className="font-medium text-sm"
>
Name
</Text>
<TextField.Root
id="channel-name"
autoFocus
size="3"
value={name}
placeholder="e.g. plan-budget"
maxLength={MAX_CHANNEL_NAME_LENGTH}
disabled={isCreating}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void submit();
}
}}
>
<TextField.Slot>
<Hash size={16} className="text-gray-10" />
</TextField.Slot>
<TextField.Slot side="right">
<Text className="text-gray-9 text-sm tabular-nums">
{remaining}
</Text>
</TextField.Slot>
</TextField.Root>
<Text className="text-gray-10 text-sm">
Channels are where conversations happen around a topic. Use a name
that is easy to find and understand.
</Text>
</Flex>

<Flex gap="3" mt="5" justify="end">
<Button
variant="solid"
disabled={!trimmed || isCreating}
disabledReason={!trimmed ? "enter a channel name" : null}
loading={isCreating}
onClick={submit}
>
Create
</Button>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}
Loading
Loading