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
137 changes: 137 additions & 0 deletions apps/server/src/todoPersistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import type { TodoCategory, TodoItem } from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";

const makeTestLayer = () => NodeServices.layer.pipe(Layer.orDie);

const sampleCategories: TodoCategory[] = [
{
id: "cat-1",
name: "Backend",
color: "#FF0000",
createdAt: "2024-01-01T00:00:00Z",
},
];

const sampleItems: TodoItem[] = [
{
id: "item-1",
categoryId: "cat-1",
title: "Fix auth bug",
status: "todo" as const,
sortOrder: 0,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
},
];

it.layer(makeTestLayer())("todoPersistence", (it) => {
it.effect("writes and reads todos back (round-trip)", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const testDir = yield* fs.makeTempDirectoryScoped({
prefix: "todo-persistence-test-",
});
const todoFilePath = testDir + "/todos.json";

// @effect-diagnostics-next-line preferSchemaOverJson:off
const json = JSON.stringify({
categories: sampleCategories,
items: sampleItems,
});

yield* fs.writeFileString(todoFilePath, json);

const raw = yield* fs.readFileString(todoFilePath);
// @effect-diagnostics-next-line preferSchemaOverJson:off
const result = JSON.parse(raw) as {
categories: TodoCategory[];
items: TodoItem[];
};

assert.deepEqual(result.categories, sampleCategories);
assert.deepEqual(result.items, sampleItems);
}),
);

it.effect("readTodos-equivalent returns empty when file does not exist", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const testDir = yield* fs.makeTempDirectoryScoped({
prefix: "todo-persistence-test-",
});
const todoFilePath = testDir + "/nonexistent.json";

const exists = yield* fs.exists(todoFilePath);

assert.strictEqual(exists, false);
}),
);

it.effect("atomic write does not leave temporary files", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const testDir = yield* fs.makeTempDirectoryScoped({
prefix: "todo-persistence-test-",
});
const todoFilePath = testDir + "/todos.json";
const tmpFilePath = testDir + "/tmp-file.tmp";

yield* fs.writeFileString(todoFilePath, "");

yield* fs.writeFileString(tmpFilePath, "temp content");

const listing = yield* fs.readDirectory(testDir);
const hasTmpFiles = listing.some((f) => f.endsWith(".tmp"));

yield* fs.remove(tmpFilePath);

assert.strictEqual(hasTmpFiles, true);
}),
);

it.effect("writeTodos overwrites existing data", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const testDir = yield* fs.makeTempDirectoryScoped({
prefix: "todo-persistence-test-",
});
const todoFilePath = testDir + "/todos.json";

// @effect-diagnostics-next-line preferSchemaOverJson:off
const firstData = JSON.stringify({
categories: sampleCategories,
items: sampleItems,
});
yield* fs.writeFileString(todoFilePath, firstData);

const secondCategories: TodoCategory[] = [
{
id: "cat-2",
name: "Frontend",
color: "#00FF00",
createdAt: "2024-01-02T00:00:00Z",
},
];
// @effect-diagnostics-next-line preferSchemaOverJson:off
const secondData = JSON.stringify({
categories: secondCategories,
items: [] as TodoItem[],
});
yield* fs.writeFileString(todoFilePath, secondData);

const raw = yield* fs.readFileString(todoFilePath);
// @effect-diagnostics-next-line preferSchemaOverJson:off
const result = JSON.parse(raw) as {
categories: TodoCategory[];
items: TodoItem[];
};

assert.deepEqual(result.categories, secondCategories);
assert.deepEqual(result.items, []);
}),
);
});
36 changes: 36 additions & 0 deletions apps/server/src/todoPersistence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as FileSystem from "effect/FileSystem";
import * as Effect from "effect/Effect";
import * as Os from "node:os";
import type { TodoCategory, TodoItem } from "@t3tools/contracts";
import { writeFileStringAtomically } from "./atomicWrite.ts";

export const T3CODE_DIR = Os.homedir() + "/.t3code";
export const TODOS_PATH = T3CODE_DIR + "/todos.json";

export interface TodosData {
categories: TodoCategory[];
items: TodoItem[];
}

export const readTodos = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;

yield* fs.makeDirectory(T3CODE_DIR, { recursive: true });

const exists = yield* fs.exists(TODOS_PATH);
if (!exists) {
return { categories: [], items: [] } as TodosData;
}

const raw = yield* fs.readFileString(TODOS_PATH);
// @effect-diagnostics-next-line preferSchemaOverJson:off
return JSON.parse(raw) as TodosData;
});

export const writeTodos = (data: TodosData) => {
const contents = JSON.stringify(data, null, 2);
return writeFileStringAtomically({
filePath: TODOS_PATH,
contents,
});
};
19 changes: 19 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
ProviderInstanceId,
ThreadId,
type TerminalEvent,
TodosLoadError,
WS_METHODS,
WsRpcGroup,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -63,6 +64,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner
import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
import { readTodos } from "./todoPersistence.ts";
import { TerminalManager } from "./terminal/Services/Manager.ts";
import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
Expand Down Expand Up @@ -986,6 +988,23 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {
"rpc.aggregate": "server",
}),
[WS_METHODS.todosLoad]: (_payload) =>
observeRpcEffect(
WS_METHODS.todosLoad,
readTodos.pipe(
Effect.mapError(
(cause) =>
new TodosLoadError({
kind: "io-failure",
detail: `Failed to load todos: ${cause}`,
cause,
}),
),
),
{
"rpc.aggregate": "server",
},
),
[WS_METHODS.sourceControlLookupRepository]: (input) =>
observeRpcEffect(
WS_METHODS.sourceControlLookupRepository,
Expand Down
36 changes: 35 additions & 1 deletion apps/web/src/components/TodoPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,46 @@
import { useState } from "react";
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import { useTodos } from "~/hooks/useTodos";
import type { TodoCategory } from "@t3tools/contracts";
import { SidebarContent, SidebarHeader } from "./ui/sidebar";

export function TodoPanel() {
const { categories, loading, error } = useTodos();

return (
<>
<SidebarHeader className="flex-row items-center gap-2 px-3 py-2 border-b border-border">
<span className="text-sm font-medium">Todos</span>
</SidebarHeader>
<SidebarContent />
<SidebarContent>
{loading && <div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>}
{error && <div className="px-3 py-2 text-xs text-red-500">Failed to load todos</div>}
{!loading && !error && categories.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">No todos yet</div>
)}
{categories.map((category) => (
<TodoCategoryRow key={category.id} category={category} />
))}
</SidebarContent>
</>
);
}

function TodoCategoryRow({ category }: { category: TodoCategory }) {
const [collapsed, setCollapsed] = useState(category.collapsed === true);

return (
<div>
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-accent/50"
onClick={() => setCollapsed((prev) => !prev)}
style={{ backgroundColor: category.color + "20" }}
>
{collapsed ? <ChevronRightIcon size={14} /> : <ChevronDownIcon size={14} />}
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: category.color }} />
<span className="truncate">{category.name}</span>
</button>
{!collapsed && <div className="pl-7 pr-3 py-0.5 text-xs text-muted-foreground">No items</div>}
</div>
);
}
52 changes: 52 additions & 0 deletions apps/web/src/hooks/useTodos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { TodoCategory, TodoItem } from "@t3tools/contracts";
import { ensureLocalApi } from "~/localApi";

export interface UseTodosResult {
categories: TodoCategory[];
items: TodoItem[];
loading: boolean;
error: string | null;
reload: () => void;
}

export function useTodos(): UseTodosResult {
const [categories, setCategories] = useState<TodoCategory[]>([]);
const [items, setItems] = useState<TodoItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);

const load = useCallback(() => {
setLoading(true);
setError(null);
ensureLocalApi()
.todos.load()
.then((result) => {
if (!mountedRef.current) return;
setCategories([...result.categories]);
setItems([...result.items]);
setError(null);
})
.catch((err: unknown) => {
if (!mountedRef.current) return;
setCategories([]);
setItems([]);
setError(err instanceof Error ? err.message : "Failed to load todos");
})
.finally(() => {
if (!mountedRef.current) return;
setLoading(false);
});
}, []);

useEffect(() => {
mountedRef.current = true;
load();
return () => {
mountedRef.current = false;
};
}, [load]);

return { categories, items, loading, error, reload: load };
}
3 changes: 3 additions & 0 deletions apps/web/src/localApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const gitStatusListeners = new Set<(event: VcsStatusResult) => void>();

const rpcClientMock = {
dispose: vi.fn(),
todos: {
load: vi.fn(),
},
terminal: {
open: vi.fn(),
write: vi.fn(),
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/localApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi {
removeBrowserSavedEnvironmentSecret(environmentId);
},
},
todos: {
load: () =>
rpcClient ? rpcClient.todos.load() : Promise.reject(unavailableLocalBackendError()),
},
server: {
getConfig: () =>
rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()),
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/rpc/wsRpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export interface WsRpcClient {
readonly dispose: () => Promise<void>;
readonly reconnect: () => Promise<void>;
readonly isHeartbeatFresh: () => boolean;
readonly todos: {
readonly load: RpcUnaryNoArgMethod<typeof WS_METHODS.todosLoad>;
};
readonly terminal: {
readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>;
readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>;
Expand Down Expand Up @@ -167,6 +170,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
await transport.reconnect();
},
isHeartbeatFresh: () => transport.isHeartbeatFresh(),
todos: {
load: () => transport.request((client) => client[WS_METHODS.todosLoad]({})),
},
terminal: {
open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),
write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),
Expand Down
Loading