fix: bug fix related to effect-ts refactor

This commit is contained in:
d-kimsuon
2025-10-17 17:16:08 +09:00
parent 1795cb499b
commit a5d81568bb
28 changed files with 1022 additions and 196 deletions

View File

@@ -1,4 +1,5 @@
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import type { CommandExecutor, FileSystem, Path } from "@effect/platform";
import { zValidator } from "@hono/zod-validator";
@@ -12,6 +13,8 @@ import { configSchema } from "../config/config";
import { env } from "../lib/env";
import { ClaudeCodeLifeCycleService } from "../service/claude-code/ClaudeCodeLifeCycleService";
import { ClaudeCodePermissionService } from "../service/claude-code/ClaudeCodePermissionService";
import { computeClaudeProjectFilePath } from "../service/claude-code/computeClaudeProjectFilePath";
import { getDirectoryListing } from "../service/directory-browser/getDirectoryListing";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
import { EventBus } from "../service/events/EventBus";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
@@ -22,6 +25,7 @@ import { getCommits } from "../service/git/getCommits";
import { getDiff } from "../service/git/getDiff";
import { getMcpList } from "../service/mcp/getMcpList";
import { claudeCommandsDirPath } from "../service/paths";
import { encodeProjectId } from "../service/project/id";
import type { ProjectMetaService } from "../service/project/ProjectMetaService";
import { ProjectRepository } from "../service/project/ProjectRepository";
import type { SessionMetaService } from "../service/session/SessionMetaService";
@@ -92,6 +96,87 @@ export const routes = (app: HonoAppType) =>
return c.json({ projects });
})
.post(
"/projects",
zValidator(
"json",
z.object({
projectPath: z.string().min(1, "Project path is required"),
}),
),
async (c) => {
const { projectPath } = c.req.valid("json");
// No project validation needed - startTask will create a new project
// if it doesn't exist when running /init command
const claudeProjectFilePath =
computeClaudeProjectFilePath(projectPath);
const projectId = encodeProjectId(claudeProjectFilePath);
const program = Effect.gen(function* () {
const result = yield* claudeCodeLifeCycleService.startTask({
baseSession: {
cwd: projectPath,
projectId,
sessionId: undefined,
},
config: c.get("config"),
message: "/init",
});
return {
result,
status: 200 as const,
};
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
const { sessionId } =
await result.result.awaitSessionFileCreated();
return c.json({
projectId: result.result.sessionProcess.def.projectId,
sessionId,
});
}
return c.json({ error: "Failed to create project" }, 500);
},
)
.get(
"/directory-browser",
zValidator(
"query",
z.object({
currentPath: z.string().optional(),
}),
),
async (c) => {
const { currentPath } = c.req.valid("query");
const rootPath = "/";
const defaultPath = homedir();
try {
const targetPath = currentPath || defaultPath;
const relativePath = targetPath.startsWith(rootPath)
? targetPath.slice(rootPath.length)
: targetPath;
const result = await getDirectoryListing(rootPath, relativePath);
return c.json(result);
} catch (error) {
console.error("Directory listing error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to list directory" }, 500);
}
},
)
.get(
"/projects/:projectId",
zValidator("query", z.object({ cursor: z.string().optional() })),
@@ -172,10 +257,11 @@ export const routes = (app: HonoAppType) =>
filteredSessions = Array.from(sessionMap.values());
}
const hasMore = sessions.length >= 20;
return {
project,
sessions: filteredSessions,
nextCursor: sessions.at(-1)?.id,
nextCursor: hasMore ? sessions.at(-1)?.id : undefined,
};
});
@@ -498,11 +584,14 @@ export const routes = (app: HonoAppType) =>
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
const { sessionId } =
await result.result.awaitSessionInitialized();
return c.json({
sessionProcess: {
id: result.result.sessionProcess.def.sessionProcessId,
projectId: result.result.sessionProcess.def.projectId,
sessionId: await result.result.awaitSessionInitialized(),
sessionId,
},
});
}
@@ -648,6 +737,16 @@ export const routes = (app: HonoAppType) =>
);
};
const onPermissionRequested = (
event: InternalEventDeclaration["permissionRequested"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("permissionRequested", {
permissionRequest: event.permissionRequest,
}),
);
};
yield* eventBus.on("sessionListChanged", onSessionListChanged);
yield* eventBus.on("sessionChanged", onSessionChanged);
yield* eventBus.on(
@@ -655,6 +754,10 @@ export const routes = (app: HonoAppType) =>
onSessionProcessChanged,
);
yield* eventBus.on("heartbeat", onHeartbeat);
yield* eventBus.on(
"permissionRequested",
onPermissionRequested,
);
const { connectionPromise } = adaptInternalEventToSSE(
rawStream,
@@ -676,6 +779,10 @@ export const routes = (app: HonoAppType) =>
onSessionProcessChanged,
);
yield* eventBus.off("heartbeat", onHeartbeat);
yield* eventBus.off(
"permissionRequested",
onPermissionRequested,
);
}),
);
},

View File

@@ -118,7 +118,12 @@ const LayerImpl = Effect.gen(function* () {
},
});
const sessionInitializedPromise = controllablePromise<string>();
const sessionInitializedPromise = controllablePromise<{
sessionId: string;
}>();
const sessionFileCreatedPromise = controllablePromise<{
sessionId: string;
}>();
setMessageGeneratorHooks({
onNewUserMessageResolved: async (message) => {
@@ -194,7 +199,9 @@ const LayerImpl = Effect.gen(function* () {
// do nothing
}
sessionInitializedPromise.resolve(message.session_id);
sessionInitializedPromise.resolve({
sessionId: message.session_id,
});
yield* eventBusService.emit("sessionListChanged", {
projectId: processState.def.projectId,
@@ -216,6 +223,10 @@ const LayerImpl = Effect.gen(function* () {
sessionProcessId: processState.def.sessionProcessId,
});
sessionFileCreatedPromise.resolve({
sessionId: message.session_id,
});
yield* virtualConversationDatabase.deleteVirtualConversations(
message.session_id,
);
@@ -329,6 +340,8 @@ const LayerImpl = Effect.gen(function* () {
daemonPromise,
awaitSessionInitialized: async () =>
await sessionInitializedPromise.promise,
awaitSessionFileCreated: async () =>
await sessionFileCreatedPromise.promise,
};
});
};

View File

@@ -1,4 +1,7 @@
import type { SDKResultMessage, SDKSystemMessage } from "@anthropic-ai/claude-code";
import type {
SDKResultMessage,
SDKSystemMessage,
} from "@anthropic-ai/claude-code";
import { Effect, Layer } from "effect";
import { describe, expect, it } from "vitest";
import { EventBus } from "../events/EventBus";
@@ -38,7 +41,6 @@ const createMockContinueTaskDef = (
baseSessionId,
});
// Helper function to create mock init context
const createMockInitContext = (sessionId: string): InitMessageContext => ({
initMessage: {
@@ -48,11 +50,12 @@ const createMockInitContext = (sessionId: string): InitMessageContext => ({
});
// Helper function to create mock result message
const createMockResultMessage = (sessionId: string): SDKResultMessage => ({
type: "result",
session_id: sessionId,
result: {},
} as SDKResultMessage);
const createMockResultMessage = (sessionId: string): SDKResultMessage =>
({
type: "result",
session_id: sessionId,
result: {},
}) as SDKResultMessage;
// Mock EventBus for testing
const MockEventBus = Layer.succeed(EventBus, {
@@ -581,7 +584,9 @@ describe("ClaudeCodeSessionProcessService", () => {
program.pipe(Effect.provide(TestLayer)),
);
const completedTask = process.tasks.find((t) => t.def.taskId === "task-1");
const completedTask = process.tasks.find(
(t) => t.def.taskId === "task-1",
);
expect(completedTask?.status).toBe("completed");
if (completedTask?.status === "completed") {
expect(completedTask.sessionId).toBe("session-1");
@@ -758,9 +763,7 @@ describe("ClaudeCodeSessionProcessService", () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const result = yield* Effect.flip(
service.getTask("non-existent-task"),
);
const result = yield* Effect.flip(service.getTask("non-existent-task"));
return result;
});
@@ -857,7 +860,11 @@ describe("ClaudeCodeSessionProcessService", () => {
});
// Continue with second task
const taskDef2 = createMockContinueTaskDef("task-2", "session-1", "session-1");
const taskDef2 = createMockContinueTaskDef(
"task-2",
"session-1",
"session-1",
);
const continueResult = yield* service.continueSessionProcess({
sessionProcessId: "process-1",

View File

@@ -0,0 +1,47 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("computeClaudeProjectFilePath", () => {
const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude";
const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects");
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../lib/env", () => ({
env: {
get: (key: string) => {
if (key === "GLOBAL_CLAUDE_DIR") {
return TEST_GLOBAL_CLAUDE_DIR;
}
return undefined;
},
},
}));
});
it("プロジェクトパスからClaudeの設定ディレクトリパスを計算する", async () => {
const { computeClaudeProjectFilePath } = await import(
"./computeClaudeProjectFilePath"
);
const projectPath = "/home/me/dev/example";
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
const result = computeClaudeProjectFilePath(projectPath);
expect(result).toBe(expected);
});
it("末尾にスラッシュがある場合も正しく処理される", async () => {
const { computeClaudeProjectFilePath } = await import(
"./computeClaudeProjectFilePath"
);
const projectPath = "/home/me/dev/example/";
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
const result = computeClaudeProjectFilePath(projectPath);
expect(result).toBe(expected);
});
});

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { claudeProjectsDirPath } from "../paths";
export function computeClaudeProjectFilePath(projectPath: string): string {
return path.join(
claudeProjectsDirPath,
projectPath.replace(/\/$/, "").replace(/\//g, "-"),
);
}

View File

@@ -0,0 +1,151 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { getDirectoryListing } from "./getDirectoryListing";
describe("getDirectoryListing", () => {
let testDir: string;
beforeEach(async () => {
testDir = join(tmpdir(), `test-dir-${Date.now()}`);
await mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
test("should list directories and files", async () => {
await mkdir(join(testDir, "subdir1"));
await mkdir(join(testDir, "subdir2"));
await writeFile(join(testDir, "file1.txt"), "content1");
await writeFile(join(testDir, "file2.txt"), "content2");
const result = await getDirectoryListing(testDir);
expect(result.entries).toHaveLength(4);
expect(result.entries).toEqual([
{ name: "subdir1", type: "directory", path: "subdir1" },
{ name: "subdir2", type: "directory", path: "subdir2" },
{ name: "file1.txt", type: "file", path: "file1.txt" },
{ name: "file2.txt", type: "file", path: "file2.txt" },
]);
expect(result.basePath).toBe("/");
expect(result.currentPath).toBe(testDir);
});
test("should navigate to subdirectory", async () => {
await mkdir(join(testDir, "parent"));
await mkdir(join(testDir, "parent", "child"));
await writeFile(join(testDir, "parent", "file.txt"), "content");
const result = await getDirectoryListing(testDir, "parent");
expect(result.entries).toHaveLength(3);
expect(result.entries).toEqual([
{ name: "..", type: "directory", path: "" },
{ name: "child", type: "directory", path: "parent/child" },
{ name: "file.txt", type: "file", path: "parent/file.txt" },
]);
expect(result.basePath).toBe("parent");
});
test("should skip hidden files and directories", async () => {
await mkdir(join(testDir, ".hidden-dir"));
await writeFile(join(testDir, ".hidden-file"), "content");
await mkdir(join(testDir, "visible-dir"));
await writeFile(join(testDir, "visible-file.txt"), "content");
const result = await getDirectoryListing(testDir);
expect(result.entries).toHaveLength(2);
expect(result.entries.some((e) => e.name.startsWith("."))).toBe(false);
});
test("should sort directories before files alphabetically", async () => {
await mkdir(join(testDir, "z-dir"));
await mkdir(join(testDir, "a-dir"));
await writeFile(join(testDir, "z-file.txt"), "content");
await writeFile(join(testDir, "a-file.txt"), "content");
const result = await getDirectoryListing(testDir);
expect(result.entries).toEqual([
{ name: "a-dir", type: "directory", path: "a-dir" },
{ name: "z-dir", type: "directory", path: "z-dir" },
{ name: "a-file.txt", type: "file", path: "a-file.txt" },
{ name: "z-file.txt", type: "file", path: "z-file.txt" },
]);
});
test("should return empty entries for non-existent directory", async () => {
const result = await getDirectoryListing(join(testDir, "non-existent"));
expect(result.entries).toEqual([]);
expect(result.basePath).toBe("/");
});
test("should prevent directory traversal", async () => {
await expect(getDirectoryListing(testDir, "../../../etc")).rejects.toThrow(
"Invalid path: outside root directory",
);
});
test("should handle basePath with leading slash", async () => {
await mkdir(join(testDir, "subdir"));
await writeFile(join(testDir, "subdir", "file.txt"), "content");
const result = await getDirectoryListing(testDir, "/subdir");
expect(result.entries).toHaveLength(2);
expect(result.entries).toEqual([
{ name: "..", type: "directory", path: "" },
{ name: "file.txt", type: "file", path: "subdir/file.txt" },
]);
expect(result.basePath).toBe("subdir");
});
test("should include parent directory entry when not at root", async () => {
await mkdir(join(testDir, "parent"));
await mkdir(join(testDir, "parent", "child"));
const result = await getDirectoryListing(testDir, "parent");
const parentEntry = result.entries.find((e) => e.name === "..");
expect(parentEntry).toEqual({
name: "..",
type: "directory",
path: "",
});
});
test("should not include parent directory entry at root", async () => {
await mkdir(join(testDir, "subdir"));
const result = await getDirectoryListing(testDir);
const parentEntry = result.entries.find((e) => e.name === "..");
expect(parentEntry).toBeUndefined();
});
test("should use absolute paths in currentPath for navigation", async () => {
await mkdir(join(testDir, "level1"));
await mkdir(join(testDir, "level1", "level2"));
const rootResult = await getDirectoryListing(testDir);
expect(rootResult.currentPath).toBe(testDir);
const level1Entry = rootResult.entries.find((e) => e.name === "level1");
expect(level1Entry).toBeDefined();
const level1Result = await getDirectoryListing(testDir, level1Entry?.path);
expect(level1Result.currentPath).toBe(join(testDir, "level1"));
const level2Entry = level1Result.entries.find((e) => e.name === "level2");
expect(level2Entry).toBeDefined();
const level2Result = await getDirectoryListing(testDir, level2Entry?.path);
expect(level2Result.currentPath).toBe(join(testDir, "level1", "level2"));
});
});

View File

@@ -0,0 +1,100 @@
import { existsSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
export type DirectoryEntry = {
name: string;
type: "file" | "directory";
path: string;
};
export type DirectoryListingResult = {
entries: DirectoryEntry[];
basePath: string;
currentPath: string;
};
export const getDirectoryListing = async (
rootPath: string,
basePath = "/",
): Promise<DirectoryListingResult> => {
const normalizedBasePath =
basePath === "/"
? ""
: basePath.startsWith("/")
? basePath.slice(1)
: basePath;
const targetPath = resolve(rootPath, normalizedBasePath);
if (!targetPath.startsWith(resolve(rootPath))) {
throw new Error("Invalid path: outside root directory");
}
if (!existsSync(targetPath)) {
return {
entries: [],
basePath: "/",
currentPath: rootPath,
};
}
try {
const dirents = await readdir(targetPath, { withFileTypes: true });
const entries: DirectoryEntry[] = [];
if (normalizedBasePath !== "") {
const parentPath = dirname(normalizedBasePath);
entries.push({
name: "..",
type: "directory",
path: parentPath === "." ? "" : parentPath,
});
}
for (const dirent of dirents) {
if (dirent.name.startsWith(".")) {
continue;
}
const entryPath = normalizedBasePath
? join(normalizedBasePath, dirent.name)
: dirent.name;
if (dirent.isDirectory()) {
entries.push({
name: dirent.name,
type: "directory",
path: entryPath,
});
} else if (dirent.isFile()) {
entries.push({
name: dirent.name,
type: "file",
path: entryPath,
});
}
}
entries.sort((a, b) => {
if (a.name === "..") return -1;
if (b.name === "..") return 1;
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
return {
entries,
basePath: normalizedBasePath || "/",
currentPath: targetPath,
};
} catch (error) {
console.error("Error reading directory:", error);
return {
entries: [],
basePath: normalizedBasePath || "/",
currentPath: targetPath,
};
}
};

View File

@@ -1,7 +1,9 @@
import { type FSWatcher, watch } from "node:fs";
import { join } from "node:path";
import { Context, Effect, Layer, Ref } from "effect";
import z from "zod";
import { claudeProjectsDirPath } from "../paths";
import { encodeProjectIdFromSessionFilePath } from "../project/id";
import { EventBus } from "./EventBus";
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
@@ -54,8 +56,13 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
if (!groups.success) return;
const { projectId, sessionId } = groups.data;
const debounceKey = `${projectId}/${sessionId}`;
const { sessionId } = groups.data;
// フルパスを構築してエンコードされた projectId を取得
const fullPath = join(claudeProjectsDirPath, filename);
const encodedProjectId =
encodeProjectIdFromSessionFilePath(fullPath);
const debounceKey = `${encodedProjectId}/${sessionId}`;
Effect.runPromise(
Effect.gen(function* () {
@@ -68,14 +75,14 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
const newTimer = setTimeout(() => {
Effect.runFork(
eventBus.emit("sessionChanged", {
projectId,
projectId: encodedProjectId,
sessionId,
}),
);
Effect.runFork(
eventBus.emit("sessionListChanged", {
projectId,
projectId: encodedProjectId,
}),
);

View File

@@ -22,7 +22,6 @@ describe("getCommits", () => {
def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
@@ -70,7 +69,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
const mockCwd = "/test/repo";
const mockOutput = "";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
@@ -92,7 +90,6 @@ def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
||missing data|
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
@@ -156,7 +153,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
it("Gitコマンドが失敗した場合", async () => {
const mockCwd = "/test/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
@@ -206,7 +202,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
const mockCwd = "/test/my repo with spaces";
const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
@@ -233,7 +228,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
def456|fix: bug|Author|2024-01-14 09:20:00 +0900
`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,

View File

@@ -1,109 +1,111 @@
import { resolve } from "node:path";
import { FileSystem } from "@effect/platform";
import { Context, Effect, Layer, Option } from "effect";
import type { InferEffect } from "../../lib/effect/types";
import { claudeProjectsDirPath } from "../paths";
import type { Project } from "../types";
import { decodeProjectId, encodeProjectId } from "./id";
import { ProjectMetaService } from "./ProjectMetaService";
const getProject = (projectId: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const projectMetaService = yield* ProjectMetaService;
const LayerImpl = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const projectMetaService = yield* ProjectMetaService;
const fullPath = decodeProjectId(projectId);
const getProject = (projectId: string) =>
Effect.gen(function* () {
const fullPath = decodeProjectId(projectId);
// Check if project directory exists
const exists = yield* fs.exists(fullPath);
if (!exists) {
return yield* Effect.fail(new Error("Project not found"));
}
// Check if project directory exists
const exists = yield* fs.exists(fullPath);
if (!exists) {
return yield* Effect.fail(new Error("Project not found"));
}
// Get file stats
const stat = yield* fs.stat(fullPath);
// Get file stats
const stat = yield* fs.stat(fullPath);
// Get project metadata
const meta = yield* projectMetaService.getProjectMeta(projectId);
// Get project metadata
const meta = yield* projectMetaService.getProjectMeta(projectId);
return {
project: {
id: projectId,
claudeProjectPath: fullPath,
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
meta,
},
};
});
const getProjects = () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const projectMetaService = yield* ProjectMetaService;
// Check if the claude projects directory exists
const dirExists = yield* fs.exists(claudeProjectsDirPath);
if (!dirExists) {
console.warn(
`Claude projects directory not found at ${claudeProjectsDirPath}`,
);
return { projects: [] };
}
// Read directory entries
const entries = yield* fs.readDirectory(claudeProjectsDirPath);
// Filter directories and map to Project objects
const projectEffects = entries.map((entry) =>
Effect.gen(function* () {
const fullPath = resolve(claudeProjectsDirPath, entry);
// Check if it's a directory
const stat = yield* Effect.tryPromise(() =>
fs.stat(fullPath).pipe(Effect.runPromise),
).pipe(Effect.catchAll(() => Effect.succeed(null)));
if (!stat || stat.type !== "Directory") {
return null;
}
const id = encodeProjectId(fullPath);
const meta = yield* projectMetaService.getProjectMeta(id);
return {
id,
return {
project: {
id: projectId,
claudeProjectPath: fullPath,
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
meta,
} satisfies Project;
}),
);
// Execute all effects in parallel and filter out nulls
const projectsWithNulls = yield* Effect.all(projectEffects, {
concurrency: "unbounded",
},
};
});
const projects = projectsWithNulls.filter((p): p is Project => p !== null);
// Sort by last modified date (newest first)
const sortedProjects = projects.sort((a, b) => {
return (
(b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) -
(a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0)
const getProjects = () =>
Effect.gen(function* () {
// Check if the claude projects directory exists
const dirExists = yield* fs.exists(claudeProjectsDirPath);
if (!dirExists) {
console.warn(
`Claude projects directory not found at ${claudeProjectsDirPath}`,
);
return { projects: [] };
}
// Read directory entries
const entries = yield* fs.readDirectory(claudeProjectsDirPath);
// Filter directories and map to Project objects
const projectEffects = entries.map((entry) =>
Effect.gen(function* () {
const fullPath = resolve(claudeProjectsDirPath, entry);
// Check if it's a directory
const stat = yield* Effect.tryPromise(() =>
fs.stat(fullPath).pipe(Effect.runPromise),
).pipe(Effect.catchAll(() => Effect.succeed(null)));
if (!stat || stat.type !== "Directory") {
return null;
}
const id = encodeProjectId(fullPath);
const meta = yield* projectMetaService.getProjectMeta(id);
return {
id,
claudeProjectPath: fullPath,
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
meta,
} satisfies Project;
}),
);
// Execute all effects in parallel and filter out nulls
const projectsWithNulls = yield* Effect.all(projectEffects, {
concurrency: "unbounded",
});
const projects = projectsWithNulls.filter(
(p): p is Project => p !== null,
);
// Sort by last modified date (newest first)
const sortedProjects = projects.sort((a, b) => {
return (
(b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) -
(a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0)
);
});
return { projects: sortedProjects };
});
return { projects: sortedProjects };
});
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
{
readonly getProject: typeof getProject;
readonly getProjects: typeof getProjects;
}
>() {
static Live = Layer.succeed(this, {
return {
getProject,
getProjects,
});
};
});
export type IProjectRepository = InferEffect<typeof LayerImpl>;
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
IProjectRepository
>() {
static Live = Layer.effect(this, LayerImpl);
}