Skip to content
Draft
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
75 changes: 75 additions & 0 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,81 @@ 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;
}

// 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>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Hash } from "@phosphor-icons/react";
import { AlertDialog, Button, Flex, Spinner, Text } from "@radix-ui/themes";
import { useState } from "react";
import { useDesktopFileSystemMutations } from "../hooks/useDesktopFileSystem";
import { useSidebarStore } from "../stores/sidebarStore";
import type { FileSystemTreeNode } from "../utils/fileSystemTree";
import { SidebarItem } from "./SidebarItem";
import { SidebarSection } from "./SidebarSection";

// Persisted collapse state is shared with the task view's repo groups; namespace
// file system keys so the two never collide.
const collapseKey = (path: string) => `fs:${path}`;

// Cap the visual indent so deeply nested paths don't push labels off-screen,
// while keeping the true depth available for keys.
const MAX_VISUAL_DEPTH = 6;

interface FileSystemTreeNodeRowProps {
node: FileSystemTreeNode;
depth: number;
collapsedSections: Set<string>;
toggleSection: (id: string) => void;
onDeleteChannel: (node: FileSystemTreeNode) => void;
}

function FileSystemTreeNodeRow({
node,
depth,
collapsedSections,
toggleSection,
onDeleteChannel,
}: FileSystemTreeNodeRowProps) {
const visualDepth = Math.min(depth, MAX_VISUAL_DEPTH);

if (!node.isFolder) {
// Leaf rows are inert for now — a click hook is intentionally left unwired.
return <SidebarItem depth={visualDepth} label={node.name} />;
}

const key = collapseKey(node.path);
const isExpanded = !collapsedSections.has(key);
// Only real top-level channel rows are deletable: depth 0 with a backing cloud
// row (derived intermediate folders have no item/id and can't be removed).
const isDeletableChannel = depth === 0 && Boolean(node.item?.id);

return (
<SidebarSection
id={key}
label={node.name}
icon={<Hash size={14} className="text-gray-10" />}
depth={visualDepth}
isExpanded={isExpanded}
onToggle={() => toggleSection(key)}
addSpacingBefore={false}
tooltipContent={node.path}
onDelete={isDeletableChannel ? () => onDeleteChannel(node) : undefined}
deleteTooltip="Delete channel"
>
{node.children.map((child) => (
<FileSystemTreeNodeRow
key={child.id}
node={child}
depth={depth + 1}
collapsedSections={collapsedSections}
toggleSection={toggleSection}
onDeleteChannel={onDeleteChannel}
/>
))}
</SidebarSection>
);
}

export function FileSystemTreeView({ nodes }: { nodes: FileSystemTreeNode[] }) {
const collapsedSections = useSidebarStore((state) => state.collapsedSections);
const toggleSection = useSidebarStore((state) => state.toggleSection);
const { deleteChannel, isDeleting } = useDesktopFileSystemMutations();
const [pendingDelete, setPendingDelete] = useState<FileSystemTreeNode | null>(
null,
);

const confirmDelete = async () => {
const id = pendingDelete?.item?.id;
if (!id) return;
try {
await deleteChannel(id);
} finally {
setPendingDelete(null);
}
};

if (nodes.length === 0) {
return (
<Flex direction="column" align="center" className="px-4 pt-6 pb-4">
<Text className="text-[13px] text-gray-10">No channels yet</Text>
</Flex>
);
}

return (
<>
<Flex direction="column">
{nodes.map((node) => (
<FileSystemTreeNodeRow
key={node.id}
node={node}
depth={0}
collapsedSections={collapsedSections}
toggleSection={toggleSection}
onDeleteChannel={setPendingDelete}
/>
))}
</Flex>

<AlertDialog.Root
open={pendingDelete !== null}
onOpenChange={(open) => {
if (!open && !isDeleting) setPendingDelete(null);
}}
>
<AlertDialog.Content maxWidth="420px" size="1">
<AlertDialog.Title className="text-sm">
Delete channel "{pendingDelete?.name}"?
</AlertDialog.Title>
<AlertDialog.Description>
<Text color="gray" className="text-[13px]">
This removes the channel for everyone in your project. This can't
be undone here.
</Text>
</AlertDialog.Description>
<Flex justify="end" gap="3" mt="3">
<AlertDialog.Cancel>
<Button
variant="soft"
color="gray"
size="1"
disabled={isDeleting}
>
Cancel
</Button>
</AlertDialog.Cancel>
<Button
variant="solid"
color="red"
size="1"
disabled={isDeleting}
onClick={confirmDelete}
>
{isDeleting ? <Spinner size="1" /> : null}
Delete
</Button>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
</>
);
}
Loading
Loading