Skip to content

Commit c405c9c

Browse files
authored
feat: add folders to sidebar (#158)
<img width="220" height="289" alt="image" src="https://github.com/user-attachments/assets/1c6b60b0-a47f-484d-b856-7aa915dc8b72" /> We persist these folders in Application Support as well. Will refactor the incredibly messy sidebar code when I do more work on it later on. This also scaffolds some logic for application storage that it _outside_ local storage. For example, in application support, we now define a data folders where we maintain a list of folders in Array via `folders.json`. We could also start storing preferences etc in there. Stuff that should persist across installs basically.
1 parent 7da7159 commit c405c9c

File tree

23 files changed

+1045
-176
lines changed

23 files changed

+1045
-176
lines changed

apps/array/forge.config.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,11 @@ const config: ForgeConfig = {
173173
generateAssets: async () => {
174174
// Generate ICNS from source PNG
175175
if (existsSync("build/[email protected]")) {
176-
console.log("Generating ICNS icon...");
177176
execSync("bash scripts/generate-icns.sh", { stdio: "inherit" });
178177
}
179178

180179
// Compile liquid glass icon to Assets.car
181180
if (existsSync("build/icon.icon")) {
182-
console.log("Compiling liquid glass icon...");
183181
execSync("bash scripts/compile-glass-icon.sh", { stdio: "inherit" });
184182
}
185183
},

apps/array/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@poshog/array",
2+
"name": "@posthog/array",
33
"version": "0.4.0",
44
"description": "Array - PostHog desktop task manager",
55
"main": ".vite/build/index.js",

apps/array/src/main/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
} from "electron";
1313
import { ANALYTICS_EVENTS } from "../types/analytics.js";
1414
import { registerAgentIpc, type TaskController } from "./services/agent.js";
15+
import { ensureDataDirectory } from "./services/data.js";
16+
import { registerFoldersIpc } from "./services/folders.js";
1517
import "./services/index.js";
1618
import { registerFsIpc } from "./services/fs.js";
1719
import { registerGitIpc } from "./services/git.js";
@@ -39,6 +41,9 @@ const taskControllers = new Map<string, TaskController>();
3941
// instead of ::1. This matches how the renderer already reaches the PostHog API.
4042
dns.setDefaultResultOrder("ipv4first");
4143

44+
// Set app name to ensure consistent userData path across platforms
45+
app.setName("Array");
46+
4247
function ensureClaudeConfigDir(): void {
4348
const existing = process.env.CLAUDE_CONFIG_DIR;
4449
if (existing) return;
@@ -170,9 +175,10 @@ function createWindow(): void {
170175
});
171176
}
172177

173-
app.whenReady().then(() => {
178+
app.whenReady().then(async () => {
174179
createWindow();
175180
ensureClaudeConfigDir();
181+
await ensureDataDirectory();
176182

177183
// Initialize PostHog analytics
178184
initializePostHog();
@@ -205,4 +211,5 @@ registerOsIpc(() => mainWindow);
205211
registerGitIpc(() => mainWindow);
206212
registerAgentIpc(taskControllers, () => mainWindow);
207213
registerFsIpc();
214+
registerFoldersIpc();
208215
registerShellIpc();

apps/array/src/main/preload.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import type {
44
OAuthTokenResponse,
55
StoredOAuthTokens,
66
} from "../shared/types/oauth";
7-
import type { ContextMenuResult } from "./services/contextMenu.types.js";
7+
import type {
8+
FolderContextMenuResult,
9+
TaskContextMenuResult,
10+
} from "./services/contextMenu.types.js";
811

912
interface MessageBoxOptions {
1013
type?: "info" | "error" | "warning" | "question";
@@ -265,6 +268,36 @@ contextBridge.exposeInMainWorld("electronAPI", {
265268
showTaskContextMenu: (
266269
taskId: string,
267270
taskTitle: string,
268-
): Promise<ContextMenuResult> =>
271+
): Promise<TaskContextMenuResult> =>
269272
ipcRenderer.invoke("show-task-context-menu", taskId, taskTitle),
273+
showFolderContextMenu: (
274+
folderId: string,
275+
folderName: string,
276+
): Promise<FolderContextMenuResult> =>
277+
ipcRenderer.invoke("show-folder-context-menu", folderId, folderName),
278+
folders: {
279+
getFolders: (): Promise<
280+
Array<{
281+
id: string;
282+
path: string;
283+
name: string;
284+
lastAccessed: string;
285+
createdAt: string;
286+
}>
287+
> => ipcRenderer.invoke("get-folders"),
288+
addFolder: (
289+
folderPath: string,
290+
): Promise<{
291+
id: string;
292+
path: string;
293+
name: string;
294+
lastAccessed: string;
295+
createdAt: string;
296+
}> => ipcRenderer.invoke("add-folder", folderPath),
297+
removeFolder: (folderId: string): Promise<void> =>
298+
ipcRenderer.invoke("remove-folder", folderId),
299+
updateFolderAccessed: (folderId: string): Promise<void> =>
300+
ipcRenderer.invoke("update-folder-accessed", folderId),
301+
clearAllData: (): Promise<void> => ipcRenderer.invoke("clear-all-data"),
302+
},
270303
});

apps/array/src/main/services/contextMenu.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { Menu, type MenuItemConstructorOptions } from "electron";
22
import { createIpcService } from "../ipc/createIpcService.js";
3-
import type { ContextMenuResult } from "./contextMenu.types.js";
3+
import type {
4+
FolderContextMenuResult,
5+
TaskContextMenuResult,
6+
} from "./contextMenu.types.js";
47

58
export type {
6-
ContextMenuAction,
7-
ContextMenuResult,
9+
FolderContextMenuAction,
10+
FolderContextMenuResult,
11+
TaskContextMenuAction,
12+
TaskContextMenuResult,
813
} from "./contextMenu.types.js";
914

1015
export const showTaskContextMenuService = createIpcService({
@@ -13,7 +18,7 @@ export const showTaskContextMenuService = createIpcService({
1318
_event,
1419
_taskId: string,
1520
_taskTitle: string,
16-
): Promise<ContextMenuResult> => {
21+
): Promise<TaskContextMenuResult> => {
1722
return new Promise((resolve) => {
1823
const template: MenuItemConstructorOptions[] = [
1924
{
@@ -38,3 +43,26 @@ export const showTaskContextMenuService = createIpcService({
3843
});
3944
},
4045
});
46+
47+
export const showFolderContextMenuService = createIpcService({
48+
channel: "show-folder-context-menu",
49+
handler: async (
50+
_event,
51+
_folderId: string,
52+
_folderName: string,
53+
): Promise<FolderContextMenuResult> => {
54+
return new Promise((resolve) => {
55+
const template: MenuItemConstructorOptions[] = [
56+
{
57+
label: "Remove folder",
58+
click: () => resolve({ action: "remove" }),
59+
},
60+
];
61+
62+
const menu = Menu.buildFromTemplate(template);
63+
menu.popup({
64+
callback: () => resolve({ action: null }),
65+
});
66+
});
67+
},
68+
});
Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
export type ContextMenuAction = "rename" | "duplicate" | "delete" | null;
1+
export type TaskContextMenuAction = "rename" | "duplicate" | "delete" | null;
22

3-
export interface ContextMenuResult {
4-
action: ContextMenuAction;
3+
export type FolderContextMenuAction = "remove" | null;
4+
5+
export interface TaskContextMenuResult {
6+
action: TaskContextMenuAction;
7+
}
8+
9+
export interface FolderContextMenuResult {
10+
action: FolderContextMenuAction;
511
}
612

713
declare global {
814
interface IElectronAPI {
915
showTaskContextMenu: (
1016
taskId: string,
1117
taskTitle: string,
12-
) => Promise<ContextMenuResult>;
18+
) => Promise<TaskContextMenuResult>;
1319
}
1420
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { app } from "electron";
4+
5+
const fsPromises = fs.promises;
6+
7+
let dataDir: string | null = null;
8+
9+
function isFileNotFoundError(error: unknown): boolean {
10+
return (error as NodeJS.ErrnoException).code === "ENOENT";
11+
}
12+
13+
function logAndThrowError(message: string, error: unknown): never {
14+
console.error(message, error);
15+
throw error;
16+
}
17+
18+
async function cleanupTempFile(filePath: string): Promise<void> {
19+
try {
20+
await fsPromises.unlink(filePath);
21+
} catch {
22+
// Ignore cleanup errors
23+
}
24+
}
25+
26+
export function getDataDirectory(): string {
27+
if (!dataDir) {
28+
const userDataPath = app.getPath("userData");
29+
dataDir = path.join(userDataPath, "data");
30+
}
31+
return dataDir;
32+
}
33+
34+
export async function ensureDataDirectory(): Promise<void> {
35+
const dir = getDataDirectory();
36+
try {
37+
await fsPromises.mkdir(dir, { recursive: true });
38+
} catch (error) {
39+
logAndThrowError("Failed to create data directory:", error);
40+
}
41+
}
42+
43+
export function getDataFilePath(filename: string): string {
44+
return path.join(getDataDirectory(), filename);
45+
}
46+
47+
export async function readDataFile<T>(filename: string): Promise<T | null> {
48+
const filePath = getDataFilePath(filename);
49+
try {
50+
const content = await fsPromises.readFile(filePath, "utf-8");
51+
return JSON.parse(content) as T;
52+
} catch (error) {
53+
if (isFileNotFoundError(error)) {
54+
return null;
55+
}
56+
logAndThrowError(`Failed to read data file ${filename}:`, error);
57+
}
58+
}
59+
60+
export async function writeDataFile<T>(
61+
filename: string,
62+
data: T,
63+
): Promise<void> {
64+
await ensureDataDirectory();
65+
const filePath = getDataFilePath(filename);
66+
const tempPath = `${filePath}.tmp`;
67+
68+
try {
69+
await fsPromises.writeFile(
70+
tempPath,
71+
JSON.stringify(data, null, 2),
72+
"utf-8",
73+
);
74+
await fsPromises.rename(tempPath, filePath);
75+
} catch (error) {
76+
await cleanupTempFile(tempPath);
77+
logAndThrowError(`Failed to write data file ${filename}:`, error);
78+
}
79+
}
80+
81+
export async function deleteDataFile(filename: string): Promise<void> {
82+
const filePath = getDataFilePath(filename);
83+
try {
84+
await fsPromises.unlink(filePath);
85+
} catch (error) {
86+
if (!isFileNotFoundError(error)) {
87+
logAndThrowError(`Failed to delete data file ${filename}:`, error);
88+
}
89+
}
90+
}
91+
92+
export async function clearDataDirectory(): Promise<void> {
93+
const dir = getDataDirectory();
94+
try {
95+
const files = await fsPromises.readdir(dir);
96+
await Promise.all(
97+
files.map((file) => fsPromises.unlink(path.join(dir, file))),
98+
);
99+
} catch (error) {
100+
if (!isFileNotFoundError(error)) {
101+
logAndThrowError("Failed to clear data directory:", error);
102+
}
103+
}
104+
}

0 commit comments

Comments
 (0)