mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-30 19:54:21 +01:00
fix: bug fix related to effect-ts refactor
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "-"),
|
||||
);
|
||||
}
|
||||
151
src/server/service/directory-browser/getDirectoryListing.test.ts
Normal file
151
src/server/service/directory-browser/getDirectoryListing.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
100
src/server/service/directory-browser/getDirectoryListing.ts
Normal file
100
src/server/service/directory-browser/getDirectoryListing.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user