mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-21 07:14:19 +01:00
fix: bug fix related to effect-ts refactor
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.85.5
|
||||
version: 5.85.5(react@19.1.1)
|
||||
@@ -1319,6 +1322,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
@@ -4570,6 +4586,26 @@ snapshots:
|
||||
'@types/react': 19.1.12
|
||||
'@types/react-dom': 19.1.9(@types/react@19.1.12)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.12
|
||||
'@types/react-dom': 19.1.9(@types/react@19.1.12)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.12)(react@19.1.1)':
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
|
||||
@@ -175,6 +175,8 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 underline underline-offset-4 decoration-primary/30 hover:decoration-primary/60 transition-colors"
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -21,7 +21,5 @@ export default async function LatestSessionPage({
|
||||
redirect(`/projects`);
|
||||
}
|
||||
|
||||
redirect(
|
||||
`/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(latestSession.id)}`,
|
||||
);
|
||||
redirect(`/projects/${projectId}/sessions/${latestSession.id}`);
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@ interface ProjectPageProps {
|
||||
|
||||
export default async function ProjectPage({ params }: ProjectPageProps) {
|
||||
const { projectId } = await params;
|
||||
redirect(`/projects/${encodeURIComponent(projectId)}/latest`);
|
||||
redirect(`/projects/${projectId}/latest`);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareIcon, PlugIcon, SettingsIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MessageSquareIcon,
|
||||
PlugIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type FC, Suspense, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||
import { SettingsControls } from "@/components/SettingsControls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProject } from "../../../../hooks/useProject";
|
||||
import { McpTab } from "./McpTab";
|
||||
@@ -157,7 +170,24 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
>
|
||||
{/* Tab Icons */}
|
||||
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="w-12 h-12 flex items-center justify-center border-b border-sidebar-border hover:bg-sidebar-accent transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 text-sidebar-foreground/70" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>プロジェクト一覧に戻る</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex flex-col p-2 space-y-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("sessions")}
|
||||
@@ -172,7 +202,14 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
>
|
||||
<MessageSquareIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>セッション一覧を表示</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("mcp")}
|
||||
@@ -187,7 +224,14 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
>
|
||||
<PlugIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>MCPサーバー設定を表示</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("settings")}
|
||||
@@ -202,7 +246,13 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>表示と通知の設定</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareIcon, PlugIcon } from "lucide-react";
|
||||
import { ArrowLeftIcon, MessageSquareIcon, PlugIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type FC, useMemo } from "react";
|
||||
import type { SidebarTab } from "@/components/GlobalSidebar";
|
||||
import { GlobalSidebar } from "@/components/GlobalSidebar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProject } from "../../../../hooks/useProject";
|
||||
import { McpTab } from "./McpTab";
|
||||
@@ -36,7 +43,7 @@ export const SessionSidebar: FC<{
|
||||
{
|
||||
id: "sessions",
|
||||
icon: MessageSquareIcon,
|
||||
title: "Sessions",
|
||||
title: "セッション一覧を表示",
|
||||
content: (
|
||||
<SessionsTab
|
||||
sessions={sessions.map((session) => ({
|
||||
@@ -54,7 +61,7 @@ export const SessionSidebar: FC<{
|
||||
{
|
||||
id: "mcp",
|
||||
icon: PlugIcon,
|
||||
title: "MCP Servers",
|
||||
title: "MCPサーバー設定を表示",
|
||||
content: <McpTab projectId={projectId} />,
|
||||
},
|
||||
],
|
||||
@@ -76,6 +83,23 @@ export const SessionSidebar: FC<{
|
||||
projectId={projectId}
|
||||
additionalTabs={additionalTabs}
|
||||
defaultActiveTab="sessions"
|
||||
headerButton={
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="w-12 h-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 text-sidebar-foreground/70" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>プロジェクト一覧に戻る</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -99,9 +99,7 @@ export const SessionsTab: FC<{
|
||||
return (
|
||||
<Link
|
||||
key={session.id}
|
||||
href={`/projects/${projectId}/sessions/${encodeURIComponent(
|
||||
session.id,
|
||||
)}`}
|
||||
href={`/projects/${projectId}/sessions/${session.id}`}
|
||||
className={cn(
|
||||
"block rounded-lg p-2.5 transition-all duration-200 hover:bg-blue-50/60 dark:hover:bg-blue-950/40 hover:border-blue-300/60 dark:hover:border-blue-700/60 hover:shadow-sm border border-sidebar-border/40 bg-sidebar/30",
|
||||
isActive &&
|
||||
|
||||
105
src/app/projects/components/CreateProjectDialog.tsx
Normal file
105
src/app/projects/components/CreateProjectDialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FC, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { honoClient } from "@/lib/api/client";
|
||||
import { DirectoryPicker } from "./DirectoryPicker";
|
||||
|
||||
export const CreateProjectDialog: FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPath, setSelectedPath] = useState<string>("");
|
||||
const router = useRouter();
|
||||
|
||||
const createProjectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await honoClient.api.projects.$post({
|
||||
json: { projectPath: selectedPath },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create project");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
onSuccess: (result) => {
|
||||
toast.success("Project created successfully");
|
||||
setOpen(false);
|
||||
router.push(`/projects/${result.projectId}/sessions/${result.sessionId}`);
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to create project",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a directory to initialize as a Claude Code project. This will
|
||||
run{" "}
|
||||
<code className="text-sm bg-muted px-1 py-0.5 rounded">/init</code>{" "}
|
||||
in the selected directory.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<DirectoryPicker
|
||||
selectedPath={selectedPath}
|
||||
onPathChange={setSelectedPath}
|
||||
/>
|
||||
{selectedPath ? (
|
||||
<div className="mt-4 p-3 bg-muted rounded-md">
|
||||
<p className="text-sm font-medium mb-1">Selected directory:</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{selectedPath}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => await createProjectMutation.mutateAsync()}
|
||||
disabled={!selectedPath || createProjectMutation.isPending}
|
||||
>
|
||||
{createProjectMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Project"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
76
src/app/projects/components/DirectoryPicker.tsx
Normal file
76
src/app/projects/components/DirectoryPicker.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Folder } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { directoryListingQuery } from "@/lib/api/queries";
|
||||
|
||||
export type DirectoryPickerProps = {
|
||||
selectedPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
};
|
||||
|
||||
export const DirectoryPicker: FC<DirectoryPickerProps> = ({ onPathChange }) => {
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data, isLoading } = useQuery(directoryListingQuery(currentPath));
|
||||
|
||||
const handleNavigate = (entryPath: string) => {
|
||||
if (entryPath === "") {
|
||||
setCurrentPath(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const newPath = `/${entryPath}`;
|
||||
setCurrentPath(newPath);
|
||||
};
|
||||
|
||||
const handleSelect = () => {
|
||||
onPathChange(data?.currentPath || "");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-md">
|
||||
<div className="p-3 border-b bg-muted/50 flex items-center justify-between">
|
||||
<p className="text-sm font-medium">
|
||||
Current: <span className="font-mono">{data?.currentPath || "~"}</span>
|
||||
</p>
|
||||
<Button size="sm" onClick={handleSelect}>
|
||||
Select This Directory
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : data?.entries && data.entries.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{data.entries
|
||||
.filter((entry) => entry.type === "directory")
|
||||
.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => handleNavigate(entry.path)}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
{entry.name === ".." ? (
|
||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-blue-500" />
|
||||
)}
|
||||
<span className="text-sm">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
No directories found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -59,7 +59,7 @@ export const ProjectList: FC = () => {
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/projects/${encodeURIComponent(project.id)}/latest`}>
|
||||
<Link href={`/projects/${project.id}/latest`}>
|
||||
View Conversations
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { HistoryIcon } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import { GlobalSidebar } from "@/components/GlobalSidebar";
|
||||
import { CreateProjectDialog } from "./components/CreateProjectDialog";
|
||||
import { ProjectList } from "./components/ProjectList";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -27,7 +28,10 @@ export default function ProjectsPage() {
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Your Projects</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Your Projects</h2>
|
||||
<CreateProjectDialog />
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-12">
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { type FC, type ReactNode, Suspense, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NotificationSettings } from "./NotificationSettings";
|
||||
import { SettingsControls } from "./SettingsControls";
|
||||
@@ -19,6 +25,7 @@ interface GlobalSidebarProps {
|
||||
className?: string;
|
||||
additionalTabs?: SidebarTab[];
|
||||
defaultActiveTab?: string;
|
||||
headerButton?: ReactNode;
|
||||
}
|
||||
|
||||
export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
@@ -26,11 +33,12 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
className,
|
||||
additionalTabs = [],
|
||||
defaultActiveTab,
|
||||
headerButton,
|
||||
}) => {
|
||||
const settingsTab: SidebarTab = {
|
||||
id: "settings",
|
||||
icon: SettingsIcon,
|
||||
title: "Settings",
|
||||
title: "表示と通知の設定",
|
||||
content: (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-sidebar-border p-4">
|
||||
@@ -96,12 +104,17 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
>
|
||||
{/* Vertical Icon Menu - Always Visible */}
|
||||
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
|
||||
<TooltipProvider>
|
||||
{headerButton && (
|
||||
<div className="border-b border-sidebar-border">{headerButton}</div>
|
||||
)}
|
||||
<div className="flex flex-col p-2 space-y-1">
|
||||
{allTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Tooltip key={tab.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
className={cn(
|
||||
@@ -111,14 +124,19 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "text-sidebar-foreground/70",
|
||||
)}
|
||||
title={tab.title}
|
||||
data-testid={`${tab.id}-tab-button`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{tab.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Content Area - Only shown when expanded */}
|
||||
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -15,7 +15,7 @@ export const usePermissionRequests = () => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Listen for permission requests from the server
|
||||
useServerEventListener("permission_requested", (data) => {
|
||||
useServerEventListener("permissionRequested", (data) => {
|
||||
if (data.permissionRequest) {
|
||||
setCurrentPermissionRequest(data.permissionRequest);
|
||||
setIsDialogOpen(true);
|
||||
|
||||
@@ -23,7 +23,6 @@ export const makeQueryClient = () =>
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 1000 * 60 * 5,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DirectoryListingResult } from "../../server/service/directory-browser/getDirectoryListing";
|
||||
import type { FileCompletionResult } from "../../server/service/file-completion/getFileCompletion";
|
||||
import { honoClient } from "./client";
|
||||
|
||||
@@ -16,6 +17,22 @@ export const projectListQuery = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const directoryListingQuery = (currentPath?: string) =>
|
||||
({
|
||||
queryKey: ["directory-listing", currentPath],
|
||||
queryFn: async (): Promise<DirectoryListingResult> => {
|
||||
const response = await honoClient.api["directory-browser"].$get({
|
||||
query: currentPath ? { currentPath } : {},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch directory listing");
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
}) as const;
|
||||
|
||||
export const projectDetailQuery = (projectId: string, cursor?: string) =>
|
||||
({
|
||||
queryKey: ["projects", projectId],
|
||||
@@ -70,7 +87,7 @@ export const sessionDetailQuery = (projectId: string, sessionId: string) =>
|
||||
throw new Error(`Failed to fetch session: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return await response.json();
|
||||
},
|
||||
}) as const;
|
||||
|
||||
|
||||
@@ -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 => ({
|
||||
const createMockResultMessage = (sessionId: string): SDKResultMessage =>
|
||||
({
|
||||
type: "result",
|
||||
session_id: sessionId,
|
||||
result: {},
|
||||
} as SDKResultMessage);
|
||||
}) 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,16 +1,18 @@
|
||||
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 LayerImpl = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const projectMetaService = yield* ProjectMetaService;
|
||||
|
||||
const getProject = (projectId: string) =>
|
||||
Effect.gen(function* () {
|
||||
const fullPath = decodeProjectId(projectId);
|
||||
|
||||
// Check if project directory exists
|
||||
@@ -37,9 +39,6 @@ const getProject = (projectId: string) =>
|
||||
|
||||
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) {
|
||||
@@ -82,7 +81,9 @@ const getProjects = () =>
|
||||
const projectsWithNulls = yield* Effect.all(projectEffects, {
|
||||
concurrency: "unbounded",
|
||||
});
|
||||
const projects = projectsWithNulls.filter((p): p is Project => p !== null);
|
||||
const projects = projectsWithNulls.filter(
|
||||
(p): p is Project => p !== null,
|
||||
);
|
||||
|
||||
// Sort by last modified date (newest first)
|
||||
const sortedProjects = projects.sort((a, b) => {
|
||||
@@ -95,15 +96,16 @@ const getProjects = () =>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export type SSEEventDeclaration = {
|
||||
processes: PublicSessionProcess[];
|
||||
};
|
||||
|
||||
permission_requested: {
|
||||
permissionRequested: {
|
||||
permissionRequest: PermissionRequest;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user