Skip to content
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
263 changes: 263 additions & 0 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,52 @@ export interface ExternalDataSource {
schemas?: ExternalDataSourceSchema[] | string;
}

export interface FolderInstructionsUser {
id?: number;
uuid?: string;
first_name?: string;
last_name?: string | null;
email?: string;
}

export interface FolderInstructions {
id: string;
content: string;
version: number;
is_latest: boolean;
created_by: FolderInstructionsUser | null;
created_at: string;
updated_at: string;
}

export interface FolderInstructionsVersion {
id: string;
version: number;
is_latest: boolean;
created_by: FolderInstructionsUser | null;
created_at: string;
}

interface PaginatedFolderInstructionsVersions {
count: number;
next: string | null;
previous: string | null;
results: FolderInstructionsVersion[];
}

// Thrown when PUT /instructions/ rejects a publish because the caller's
// `base_version` is older than the current latest. Callers can re-fetch and
// retry against the new latest.
export class FolderInstructionsConflictError extends Error {
status = 409;
constructor(
message = "Folder instructions changed since you started editing",
) {
super(message);
this.name = "FolderInstructionsConflictError";
}
}

export interface TaskArtifactUploadRequest {
name: string;
type: "user_attachment";
Expand Down Expand Up @@ -958,6 +1004,223 @@ 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}`,
);
}
}

// Per-folder, versioned markdown instructions for a desktop folder. The
// endpoint is keyed on the FileSystem row id (must be `type === "folder"`).
// Returns the current latest version or null when none has been published.
async getDesktopFolderInstructions(
folderId: string,
): Promise<FolderInstructions | null> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(
`Failed to fetch folder instructions: ${response.statusText}`,
);
}
return (await response.json()) as FolderInstructions;
}

// Publish a new version of the folder's instructions. Pass `base_version`
// (the latest version the editor was started from) for optimistic
// concurrency; use 0 when no instructions exist yet. A 409 turns into a
// typed `FolderInstructionsConflictError` so the UI can prompt to reload.
async putDesktopFolderInstructions(
folderId: string,
input: { content: string; base_version?: number },
): Promise<FolderInstructions> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
const response = await this.api.fetcher.fetch({
method: "put",
url,
path: urlPath,
overrides: {
body: JSON.stringify(input),
},
});
if (response.status === 409) {
throw new FolderInstructionsConflictError();
}
if (!response.ok) {
throw new Error(
`Failed to publish folder instructions: ${response.statusText}`,
);
}
return (await response.json()) as FolderInstructions;
}

// Soft-delete all versions of this folder's instructions. The folder row
// itself is not affected.
async deleteDesktopFolderInstructions(folderId: string): Promise<void> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/`;
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 folder instructions: ${response.statusText}`,
);
}
}

// List version metadata (no content) newest-first. Single page is enough for
// the typical UI; we cap follow-up pages to avoid runaway pagination on
// pathological histories.
async listDesktopFolderInstructionVersions(
folderId: string,
): Promise<FolderInstructionsVersion[]> {
const VERSIONS_MAX_PAGES = 20;
const teamId = await this.getTeamId();
const all: FolderInstructionsVersion[] = [];
let urlPath = `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(folderId)}/instructions/versions/`;
for (let i = 0; i < VERSIONS_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 folder instruction versions: ${response.statusText}`,
);
}
const page =
(await response.json()) as PaginatedFolderInstructionsVersions;
all.push(...page.results);
if (!page.next) return all;
const nextUrl = new URL(page.next);
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
}
log.warn(
`listDesktopFolderInstructionVersions hit MAX_PAGES (${VERSIONS_MAX_PAGES}); returning partial results`,
{ folderId, returned: all.length },
);
return all;
}

async getTask(taskId: string) {
const teamId = await this.getTeamId();
const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, {
Expand Down
Loading
Loading