@ripla/godd-mcp 0.1.3-canary.21 → 0.1.3-canary.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
- """Files router: get/put file content, history."""
1
+ """Files router: get/put/create/delete/rename file content, history."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
6
 
7
7
  from fastapi import APIRouter, Depends, HTTPException
8
- from pydantic import BaseModel
8
+ from pydantic import BaseModel, Field
9
9
  from sqlalchemy.ext.asyncio import AsyncSession
10
10
 
11
11
  from app.config import settings
@@ -18,6 +18,7 @@ from app.services.github import (
18
18
  get_github_config,
19
19
  get_mock_file_content,
20
20
  )
21
+ from app.services.github_pr import commit_file, delete_file, get_file_sha
21
22
  from app.services.pr_chain import save_to_working_branch
22
23
 
23
24
  router = APIRouter(prefix="/api", tags=["files"])
@@ -34,6 +35,23 @@ class FileUpdateBody(BaseModel):
34
35
  styles: dict | None = None
35
36
 
36
37
 
38
+ class FileCreateBody(BaseModel):
39
+ """File creation request body."""
40
+
41
+ path: str = Field(..., min_length=1)
42
+ content: str = ""
43
+ message: str | None = None
44
+
45
+
46
+ class FileRenameBody(BaseModel):
47
+ """File rename/move request body."""
48
+
49
+ new_path: str = Field(..., min_length=1, alias="newPath")
50
+ message: str | None = None
51
+
52
+ model_config = {"populate_by_name": True}
53
+
54
+
37
55
  @router.get("/files/{file_path:path}")
38
56
  async def get_file(file_path: str):
39
57
  """Get file content + optional sidecar styles."""
@@ -113,3 +131,111 @@ async def put_file(
113
131
  return result
114
132
  except Exception as e:
115
133
  raise HTTPException(status_code=500, detail=str(e)) from e
134
+
135
+
136
+ @router.post("/files", status_code=201)
137
+ async def create_file(
138
+ body: FileCreateBody,
139
+ payload: dict = Depends(auth_dependency),
140
+ _: dict = Depends(require_role("admin", "editor")),
141
+ ):
142
+ """Create a new file on the default branch."""
143
+ if settings.is_mock_mode():
144
+ raise HTTPException(
145
+ status_code=400, detail="Mock mode: file creation disabled",
146
+ )
147
+ try:
148
+ config = get_github_config()
149
+ existing = await get_file_sha(body.path, config["branch"], config)
150
+ if existing is not None:
151
+ raise HTTPException(
152
+ status_code=409, detail="File already exists",
153
+ )
154
+ message = body.message or f"docs: create {body.path.split('/')[-1]}"
155
+ result = await commit_file(
156
+ config["branch"], body.path, body.content, message, None, config,
157
+ )
158
+ return {"path": body.path, "sha": result["sha"]}
159
+ except HTTPException:
160
+ raise
161
+ except Exception as e:
162
+ raise HTTPException(status_code=500, detail=str(e)) from e
163
+
164
+
165
+ @router.delete("/files/{file_path:path}")
166
+ async def remove_file(
167
+ file_path: str,
168
+ payload: dict = Depends(auth_dependency),
169
+ _: dict = Depends(require_role("admin", "editor")),
170
+ ):
171
+ """Delete a file from the default branch."""
172
+ if settings.is_mock_mode():
173
+ raise HTTPException(
174
+ status_code=400, detail="Mock mode: file deletion disabled",
175
+ )
176
+ if not file_path:
177
+ raise HTTPException(status_code=400, detail="File path is required")
178
+ try:
179
+ config = get_github_config()
180
+ sha = await get_file_sha(file_path, config["branch"], config)
181
+ if sha is None:
182
+ raise HTTPException(status_code=404, detail="File not found")
183
+ message = f"docs: delete {file_path.split('/')[-1]}"
184
+ await delete_file(
185
+ config["branch"], file_path, sha, message, config,
186
+ )
187
+ return {"success": True, "path": file_path}
188
+ except HTTPException:
189
+ raise
190
+ except Exception as e:
191
+ raise HTTPException(status_code=500, detail=str(e)) from e
192
+
193
+
194
+ @router.patch("/files/{file_path:path}")
195
+ async def rename_file(
196
+ file_path: str,
197
+ body: FileRenameBody,
198
+ payload: dict = Depends(auth_dependency),
199
+ _: dict = Depends(require_role("admin", "editor")),
200
+ ):
201
+ """Rename/move a file (copy to new path + delete old)."""
202
+ if not file_path:
203
+ raise HTTPException(status_code=400, detail="File path is required")
204
+ if body.new_path == file_path:
205
+ raise HTTPException(
206
+ status_code=400, detail="New path is the same as current path",
207
+ )
208
+ if settings.is_mock_mode():
209
+ raise HTTPException(
210
+ status_code=400, detail="Mock mode: file rename disabled",
211
+ )
212
+ try:
213
+ config = get_github_config()
214
+ existing = await get_file_sha(body.new_path, config["branch"], config)
215
+ if existing is not None:
216
+ raise HTTPException(
217
+ status_code=409, detail="Target path already exists",
218
+ )
219
+
220
+ file_data = await fetch_file_content(file_path, config)
221
+ old_name = file_path.split("/")[-1]
222
+ new_name = body.new_path.split("/")[-1]
223
+ message = body.message or f"docs: rename {old_name} → {new_name}"
224
+
225
+ result = await commit_file(
226
+ config["branch"], body.new_path, file_data["content"],
227
+ message, None, config,
228
+ )
229
+ await delete_file(
230
+ config["branch"], file_path, file_data["sha"],
231
+ message, config,
232
+ )
233
+ return {
234
+ "oldPath": file_path,
235
+ "newPath": body.new_path,
236
+ "sha": result["sha"],
237
+ }
238
+ except HTTPException:
239
+ raise
240
+ except Exception as e:
241
+ raise HTTPException(status_code=500, detail=str(e)) from e
@@ -104,6 +104,42 @@ async def commit_file(
104
104
  return {"sha": resp.json()["content"]["sha"]}
105
105
 
106
106
 
107
+ async def delete_file(
108
+ branch_name: str,
109
+ file_path: str,
110
+ sha: str,
111
+ message: str,
112
+ config: dict,
113
+ ) -> None:
114
+ """Delete a file via Contents API."""
115
+ url = (
116
+ f"https://api.github.com/repos/{config['owner']}/{config['repo']}"
117
+ f"/contents/{file_path}"
118
+ )
119
+ body = {"message": message, "sha": sha, "branch": branch_name}
120
+ async with httpx.AsyncClient() as client:
121
+ resp = await client.request(
122
+ "DELETE", url, headers=_headers(config), json=body,
123
+ )
124
+ resp.raise_for_status()
125
+
126
+
127
+ async def get_file_sha(
128
+ file_path: str, branch: str, config: dict,
129
+ ) -> str | None:
130
+ """Get the SHA of a file (needed for delete/rename)."""
131
+ url = (
132
+ f"https://api.github.com/repos/{config['owner']}/{config['repo']}"
133
+ f"/contents/{file_path}?ref={branch}"
134
+ )
135
+ async with httpx.AsyncClient() as client:
136
+ resp = await client.get(url, headers=_headers(config))
137
+ if resp.status_code == 404:
138
+ return None
139
+ resp.raise_for_status()
140
+ return resp.json().get("sha")
141
+
142
+
107
143
  async def find_open_auto_prs(config: dict) -> list:
108
144
  """Find open PRs with docs-auto label."""
109
145
  url = f"https://api.github.com/repos/{config['owner']}/{config['repo']}/pulls?state=open&per_page=100"
@@ -69,3 +69,120 @@ class TestPutFile:
69
69
  headers=auth_headers(viewer_token),
70
70
  )
71
71
  assert resp.status_code == 403
72
+
73
+
74
+ class TestCreateFile:
75
+ async def test_create_file_mock_mode_rejected(
76
+ self, client: AsyncClient, mock_db, admin_token,
77
+ ):
78
+ """File creation is disabled in mock mode."""
79
+ resp = await client.post(
80
+ "/api/files",
81
+ json={"path": "docs/new.md", "content": "# New"},
82
+ headers=auth_headers(admin_token),
83
+ )
84
+ assert resp.status_code == 400
85
+ assert "Mock mode" in resp.json()["detail"]
86
+
87
+ async def test_create_file_unauthorized(self, client: AsyncClient):
88
+ """POST without auth returns 401."""
89
+ resp = await client.post(
90
+ "/api/files",
91
+ json={"path": "docs/x.md"},
92
+ )
93
+ assert resp.status_code == 401
94
+
95
+ async def test_create_file_viewer_forbidden(
96
+ self, client: AsyncClient, mock_db, viewer_token,
97
+ ):
98
+ """Viewers cannot create files."""
99
+ resp = await client.post(
100
+ "/api/files",
101
+ json={"path": "docs/x.md"},
102
+ headers=auth_headers(viewer_token),
103
+ )
104
+ assert resp.status_code == 403
105
+
106
+ async def test_create_file_empty_path_rejected(
107
+ self, client: AsyncClient, mock_db, admin_token,
108
+ ):
109
+ """Empty path is rejected by validation."""
110
+ resp = await client.post(
111
+ "/api/files",
112
+ json={"path": "", "content": "x"},
113
+ headers=auth_headers(admin_token),
114
+ )
115
+ assert resp.status_code == 422
116
+
117
+
118
+ class TestDeleteFile:
119
+ async def test_delete_file_mock_mode_rejected(
120
+ self, client: AsyncClient, mock_db, admin_token,
121
+ ):
122
+ """File deletion is disabled in mock mode."""
123
+ resp = await client.delete(
124
+ "/api/files/docs/old.md",
125
+ headers=auth_headers(admin_token),
126
+ )
127
+ assert resp.status_code == 400
128
+ assert "Mock mode" in resp.json()["detail"]
129
+
130
+ async def test_delete_file_unauthorized(self, client: AsyncClient):
131
+ """DELETE without auth returns 401."""
132
+ resp = await client.delete("/api/files/docs/x.md")
133
+ assert resp.status_code == 401
134
+
135
+ async def test_delete_file_viewer_forbidden(
136
+ self, client: AsyncClient, mock_db, viewer_token,
137
+ ):
138
+ """Viewers cannot delete files."""
139
+ resp = await client.delete(
140
+ "/api/files/docs/x.md",
141
+ headers=auth_headers(viewer_token),
142
+ )
143
+ assert resp.status_code == 403
144
+
145
+
146
+ class TestRenameFile:
147
+ async def test_rename_file_mock_mode_rejected(
148
+ self, client: AsyncClient, mock_db, admin_token,
149
+ ):
150
+ """File rename is disabled in mock mode."""
151
+ resp = await client.patch(
152
+ "/api/files/docs/old.md",
153
+ json={"newPath": "docs/new.md"},
154
+ headers=auth_headers(admin_token),
155
+ )
156
+ assert resp.status_code == 400
157
+ assert "Mock mode" in resp.json()["detail"]
158
+
159
+ async def test_rename_file_unauthorized(self, client: AsyncClient):
160
+ """PATCH without auth returns 401."""
161
+ resp = await client.patch(
162
+ "/api/files/docs/x.md",
163
+ json={"newPath": "docs/y.md"},
164
+ )
165
+ assert resp.status_code == 401
166
+
167
+ async def test_rename_file_viewer_forbidden(
168
+ self, client: AsyncClient, mock_db, viewer_token,
169
+ ):
170
+ """Viewers cannot rename files."""
171
+ resp = await client.patch(
172
+ "/api/files/docs/x.md",
173
+ json={"newPath": "docs/y.md"},
174
+ headers=auth_headers(viewer_token),
175
+ )
176
+ assert resp.status_code == 403
177
+
178
+ async def test_rename_file_same_path_rejected(
179
+ self, client: AsyncClient, mock_db, admin_token,
180
+ ):
181
+ """Rename to the same path is rejected."""
182
+ resp = await client.patch(
183
+ "/api/files/docs/x.md",
184
+ json={"newPath": "docs/x.md"},
185
+ headers=auth_headers(admin_token),
186
+ )
187
+ assert resp.status_code == 400
188
+ assert "same" in resp.json()["detail"]
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import FileContextMenu from "./FileContextMenu";
4
+
5
+ describe("FileContextMenu", () => {
6
+ const defaultProps = {
7
+ x: 100,
8
+ y: 200,
9
+ isDir: false,
10
+ onAction: vi.fn(),
11
+ onClose: vi.fn(),
12
+ };
13
+
14
+ it("renders rename and delete for files", () => {
15
+ render(<FileContextMenu {...defaultProps} />);
16
+ expect(screen.getByText("名前変更")).toBeTruthy();
17
+ expect(screen.getByText("削除")).toBeTruthy();
18
+ expect(screen.queryByText("新規ファイル")).toBeNull();
19
+ });
20
+
21
+ it("renders create, rename, and delete for directories", () => {
22
+ render(<FileContextMenu {...defaultProps} isDir />);
23
+ expect(screen.getByText("新規ファイル")).toBeTruthy();
24
+ expect(screen.getByText("名前変更")).toBeTruthy();
25
+ expect(screen.getByText("削除")).toBeTruthy();
26
+ });
27
+
28
+ it("calls onAction with correct action type", () => {
29
+ const onAction = vi.fn();
30
+ render(<FileContextMenu {...defaultProps} isDir onAction={onAction} />);
31
+
32
+ fireEvent.click(screen.getByText("新規ファイル"));
33
+ expect(onAction).toHaveBeenCalledWith("create");
34
+ });
35
+
36
+ it("calls onClose on Escape key", () => {
37
+ const onClose = vi.fn();
38
+ render(<FileContextMenu {...defaultProps} onClose={onClose} />);
39
+
40
+ fireEvent.keyDown(document, { key: "Escape" });
41
+ expect(onClose).toHaveBeenCalledOnce();
42
+ });
43
+
44
+ it("calls onClose on outside click", () => {
45
+ const onClose = vi.fn();
46
+ render(<FileContextMenu {...defaultProps} onClose={onClose} />);
47
+
48
+ fireEvent.mouseDown(document.body);
49
+ expect(onClose).toHaveBeenCalledOnce();
50
+ });
51
+ });
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ export type FileAction = "create" | "rename" | "delete";
4
+
5
+ type Props = {
6
+ x: number;
7
+ y: number;
8
+ isDir: boolean;
9
+ onAction: (action: FileAction) => void;
10
+ onClose: () => void;
11
+ };
12
+
13
+ export default function FileContextMenu({ x, y, isDir, onAction, onClose }: Props) {
14
+ const ref = useRef<HTMLDivElement>(null);
15
+
16
+ useEffect(() => {
17
+ function handleClick(e: MouseEvent) {
18
+ if (ref.current && !ref.current.contains(e.target as Node)) {
19
+ onClose();
20
+ }
21
+ }
22
+ function handleEsc(e: KeyboardEvent) {
23
+ if (e.key === "Escape") onClose();
24
+ }
25
+ document.addEventListener("mousedown", handleClick);
26
+ document.addEventListener("keydown", handleEsc);
27
+ return () => {
28
+ document.removeEventListener("mousedown", handleClick);
29
+ document.removeEventListener("keydown", handleEsc);
30
+ };
31
+ }, [onClose]);
32
+
33
+ const items: { label: string; action: FileAction; icon: string }[] = [];
34
+ if (isDir) {
35
+ items.push({ label: "新規ファイル", action: "create", icon: "📄" });
36
+ }
37
+ items.push({ label: "名前変更", action: "rename", icon: "✏️" });
38
+ items.push({ label: "削除", action: "delete", icon: "🗑️" });
39
+
40
+ return (
41
+ <div
42
+ ref={ref}
43
+ className="fixed z-50 bg-gray-800 border border-gray-600 rounded shadow-lg py-1 min-w-[140px]"
44
+ style={{ left: x, top: y }}
45
+ >
46
+ {items.map((item) => (
47
+ <button
48
+ key={item.action}
49
+ onClick={() => onAction(item.action)}
50
+ className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-700 flex items-center gap-2 ${
51
+ item.action === "delete" ? "text-red-400 hover:text-red-300" : "text-gray-200"
52
+ }`}
53
+ >
54
+ <span>{item.icon}</span>
55
+ <span>{item.label}</span>
56
+ </button>
57
+ ))}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { CreateFileDialog, RenameDialog, DeleteConfirmDialog } from "./FileDialogs";
4
+
5
+ describe("CreateFileDialog", () => {
6
+ it("renders with parent path", () => {
7
+ render(
8
+ <CreateFileDialog
9
+ parentPath="docs/001_project"
10
+ onConfirm={vi.fn()}
11
+ onCancel={vi.fn()}
12
+ />,
13
+ );
14
+ expect(screen.getByText("新規ファイル作成")).toBeTruthy();
15
+ expect(screen.getByText("場所: docs/001_project/")).toBeTruthy();
16
+ });
17
+
18
+ it("disables submit when name is empty", () => {
19
+ render(
20
+ <CreateFileDialog
21
+ parentPath="docs"
22
+ onConfirm={vi.fn()}
23
+ onCancel={vi.fn()}
24
+ />,
25
+ );
26
+ const submitBtn = screen.getByText("作成");
27
+ expect(submitBtn).toHaveProperty("disabled", true);
28
+ });
29
+
30
+ it("calls onConfirm with trimmed name on submit", () => {
31
+ const onConfirm = vi.fn();
32
+ render(
33
+ <CreateFileDialog
34
+ parentPath="docs"
35
+ onConfirm={onConfirm}
36
+ onCancel={vi.fn()}
37
+ />,
38
+ );
39
+ const input = screen.getByPlaceholderText("ファイル名 (例: README.md)");
40
+ fireEvent.change(input, { target: { value: " test.md " } });
41
+ fireEvent.submit(input.closest("form")!);
42
+ expect(onConfirm).toHaveBeenCalledWith("test.md");
43
+ });
44
+
45
+ it("calls onCancel on cancel button", () => {
46
+ const onCancel = vi.fn();
47
+ render(
48
+ <CreateFileDialog
49
+ parentPath="docs"
50
+ onConfirm={vi.fn()}
51
+ onCancel={onCancel}
52
+ />,
53
+ );
54
+ fireEvent.click(screen.getByText("キャンセル"));
55
+ expect(onCancel).toHaveBeenCalledOnce();
56
+ });
57
+ });
58
+
59
+ describe("RenameDialog", () => {
60
+ it("renders with current name pre-filled", () => {
61
+ render(
62
+ <RenameDialog
63
+ currentName="README.md"
64
+ onConfirm={vi.fn()}
65
+ onCancel={vi.fn()}
66
+ />,
67
+ );
68
+ expect(screen.getByText("名前変更")).toBeTruthy();
69
+ const input = screen.getByDisplayValue("README.md");
70
+ expect(input).toBeTruthy();
71
+ });
72
+
73
+ it("disables submit when name is unchanged", () => {
74
+ render(
75
+ <RenameDialog
76
+ currentName="test.md"
77
+ onConfirm={vi.fn()}
78
+ onCancel={vi.fn()}
79
+ />,
80
+ );
81
+ const submitBtn = screen.getByText("変更");
82
+ expect(submitBtn).toHaveProperty("disabled", true);
83
+ });
84
+
85
+ it("calls onConfirm with new name", () => {
86
+ const onConfirm = vi.fn();
87
+ render(
88
+ <RenameDialog
89
+ currentName="old.md"
90
+ onConfirm={onConfirm}
91
+ onCancel={vi.fn()}
92
+ />,
93
+ );
94
+ const input = screen.getByDisplayValue("old.md");
95
+ fireEvent.change(input, { target: { value: "new.md" } });
96
+ fireEvent.submit(input.closest("form")!);
97
+ expect(onConfirm).toHaveBeenCalledWith("new.md");
98
+ });
99
+ });
100
+
101
+ describe("DeleteConfirmDialog", () => {
102
+ it("renders file name and warning", () => {
103
+ render(
104
+ <DeleteConfirmDialog
105
+ fileName="test.md"
106
+ onConfirm={vi.fn()}
107
+ onCancel={vi.fn()}
108
+ />,
109
+ );
110
+ expect(screen.getByText("ファイル削除")).toBeTruthy();
111
+ expect(screen.getByText("test.md")).toBeTruthy();
112
+ expect(screen.getByText("この操作は取り消せません。")).toBeTruthy();
113
+ });
114
+
115
+ it("calls onConfirm on delete button", () => {
116
+ const onConfirm = vi.fn();
117
+ render(
118
+ <DeleteConfirmDialog
119
+ fileName="x.md"
120
+ onConfirm={onConfirm}
121
+ onCancel={vi.fn()}
122
+ />,
123
+ );
124
+ fireEvent.click(screen.getByText("削除"));
125
+ expect(onConfirm).toHaveBeenCalledOnce();
126
+ });
127
+
128
+ it("calls onCancel on cancel button", () => {
129
+ const onCancel = vi.fn();
130
+ render(
131
+ <DeleteConfirmDialog
132
+ fileName="x.md"
133
+ onConfirm={vi.fn()}
134
+ onCancel={onCancel}
135
+ />,
136
+ );
137
+ fireEvent.click(screen.getByText("キャンセル"));
138
+ expect(onCancel).toHaveBeenCalledOnce();
139
+ });
140
+ });
@@ -0,0 +1,174 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+
3
+ type DialogBackdropProps = {
4
+ children: React.ReactNode;
5
+ onClose: () => void;
6
+ };
7
+
8
+ function DialogBackdrop({ children, onClose }: DialogBackdropProps) {
9
+ return (
10
+ <div
11
+ className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"
12
+ onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
13
+ onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
14
+ role="dialog"
15
+ aria-modal="true"
16
+ >
17
+ {children}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ type CreateFileDialogProps = {
23
+ parentPath: string;
24
+ onConfirm: (fileName: string) => void;
25
+ onCancel: () => void;
26
+ };
27
+
28
+ export function CreateFileDialog({ parentPath, onConfirm, onCancel }: CreateFileDialogProps) {
29
+ const [name, setName] = useState("");
30
+ const inputRef = useRef<HTMLInputElement>(null);
31
+
32
+ useEffect(() => {
33
+ inputRef.current?.focus();
34
+ }, []);
35
+
36
+ function handleSubmit(e: React.FormEvent) {
37
+ e.preventDefault();
38
+ const trimmed = name.trim();
39
+ if (!trimmed) return;
40
+ onConfirm(trimmed);
41
+ }
42
+
43
+ return (
44
+ <DialogBackdrop onClose={onCancel}>
45
+ <form
46
+ onSubmit={handleSubmit}
47
+ className="bg-gray-800 border border-gray-600 rounded-lg shadow-xl p-5 w-96"
48
+ onClick={(e) => e.stopPropagation()}
49
+ >
50
+ <h3 className="text-sm font-bold text-gray-100 mb-3">新規ファイル作成</h3>
51
+ <p className="text-xs text-gray-400 mb-2 truncate">場所: {parentPath}/</p>
52
+ <input
53
+ ref={inputRef}
54
+ type="text"
55
+ value={name}
56
+ onChange={(e) => setName(e.target.value)}
57
+ placeholder="ファイル名 (例: README.md)"
58
+ className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500"
59
+ />
60
+ <div className="flex justify-end gap-2 mt-4">
61
+ <button
62
+ type="button"
63
+ onClick={onCancel}
64
+ className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200"
65
+ >
66
+ キャンセル
67
+ </button>
68
+ <button
69
+ type="submit"
70
+ disabled={!name.trim()}
71
+ className="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
72
+ >
73
+ 作成
74
+ </button>
75
+ </div>
76
+ </form>
77
+ </DialogBackdrop>
78
+ );
79
+ }
80
+
81
+ type RenameDialogProps = {
82
+ currentName: string;
83
+ onConfirm: (newName: string) => void;
84
+ onCancel: () => void;
85
+ };
86
+
87
+ export function RenameDialog({ currentName, onConfirm, onCancel }: RenameDialogProps) {
88
+ const [name, setName] = useState(currentName);
89
+ const inputRef = useRef<HTMLInputElement>(null);
90
+
91
+ useEffect(() => {
92
+ inputRef.current?.focus();
93
+ inputRef.current?.select();
94
+ }, []);
95
+
96
+ function handleSubmit(e: React.FormEvent) {
97
+ e.preventDefault();
98
+ const trimmed = name.trim();
99
+ if (!trimmed || trimmed === currentName) return;
100
+ onConfirm(trimmed);
101
+ }
102
+
103
+ return (
104
+ <DialogBackdrop onClose={onCancel}>
105
+ <form
106
+ onSubmit={handleSubmit}
107
+ className="bg-gray-800 border border-gray-600 rounded-lg shadow-xl p-5 w-96"
108
+ onClick={(e) => e.stopPropagation()}
109
+ >
110
+ <h3 className="text-sm font-bold text-gray-100 mb-3">名前変更</h3>
111
+ <input
112
+ ref={inputRef}
113
+ type="text"
114
+ value={name}
115
+ onChange={(e) => setName(e.target.value)}
116
+ className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-sm text-gray-100 focus:outline-none focus:border-blue-500"
117
+ />
118
+ <div className="flex justify-end gap-2 mt-4">
119
+ <button
120
+ type="button"
121
+ onClick={onCancel}
122
+ className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200"
123
+ >
124
+ キャンセル
125
+ </button>
126
+ <button
127
+ type="submit"
128
+ disabled={!name.trim() || name.trim() === currentName}
129
+ className="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
130
+ >
131
+ 変更
132
+ </button>
133
+ </div>
134
+ </form>
135
+ </DialogBackdrop>
136
+ );
137
+ }
138
+
139
+ type DeleteConfirmDialogProps = {
140
+ fileName: string;
141
+ onConfirm: () => void;
142
+ onCancel: () => void;
143
+ };
144
+
145
+ export function DeleteConfirmDialog({ fileName, onConfirm, onCancel }: DeleteConfirmDialogProps) {
146
+ return (
147
+ <DialogBackdrop onClose={onCancel}>
148
+ <div
149
+ className="bg-gray-800 border border-gray-600 rounded-lg shadow-xl p-5 w-96"
150
+ onClick={(e) => e.stopPropagation()}
151
+ >
152
+ <h3 className="text-sm font-bold text-red-400 mb-3">ファイル削除</h3>
153
+ <p className="text-sm text-gray-300 mb-1">
154
+ <span className="font-mono text-gray-100">{fileName}</span> を削除しますか?
155
+ </p>
156
+ <p className="text-xs text-gray-500 mb-4">この操作は取り消せません。</p>
157
+ <div className="flex justify-end gap-2">
158
+ <button
159
+ onClick={onCancel}
160
+ className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200"
161
+ >
162
+ キャンセル
163
+ </button>
164
+ <button
165
+ onClick={onConfirm}
166
+ className="px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded"
167
+ >
168
+ 削除
169
+ </button>
170
+ </div>
171
+ </div>
172
+ </DialogBackdrop>
173
+ );
174
+ }
@@ -99,3 +99,46 @@ export async function fetchMe(): Promise<UserInfo | null> {
99
99
  if (!res.ok) return null;
100
100
  return res.json();
101
101
  }
102
+
103
+ export async function createFileApi(
104
+ path: string,
105
+ content = "",
106
+ ): Promise<{ path: string; sha: string }> {
107
+ const res = await apiFetch("/files", {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ path, content }),
111
+ });
112
+ if (!res.ok) {
113
+ const detail = await res.json().catch(() => null);
114
+ throw new Error(detail?.detail ?? `Create failed: ${res.status}`);
115
+ }
116
+ return res.json();
117
+ }
118
+
119
+ export async function deleteFileApi(
120
+ path: string,
121
+ ): Promise<{ success: boolean }> {
122
+ const res = await apiFetch(`/files/${path}`, { method: "DELETE" });
123
+ if (!res.ok) {
124
+ const detail = await res.json().catch(() => null);
125
+ throw new Error(detail?.detail ?? `Delete failed: ${res.status}`);
126
+ }
127
+ return res.json();
128
+ }
129
+
130
+ export async function renameFileApi(
131
+ oldPath: string,
132
+ newPath: string,
133
+ ): Promise<{ oldPath: string; newPath: string; sha: string }> {
134
+ const res = await apiFetch(`/files/${oldPath}`, {
135
+ method: "PATCH",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({ newPath }),
138
+ });
139
+ if (!res.ok) {
140
+ const detail = await res.json().catch(() => null);
141
+ throw new Error(detail?.detail ?? `Rename failed: ${res.status}`);
142
+ }
143
+ return res.json();
144
+ }
@@ -5,8 +5,12 @@ import { useAuth } from "@/contexts/AuthContext";
5
5
  import { useToast } from "@/contexts/ToastContext";
6
6
  import { useEditSession } from "@/hooks/useEditSession";
7
7
  import { useAutoSave } from "@/hooks/useAutoSave";
8
+ import { createFileApi, deleteFileApi, renameFileApi } from "@/lib/api";
8
9
  import EditToolbar from "@/components/EditToolbar";
9
10
  import SaveStatusIndicator from "@/components/SaveStatusIndicator";
11
+ import FileContextMenu from "@/components/FileContextMenu";
12
+ import type { FileAction } from "@/components/FileContextMenu";
13
+ import { CreateFileDialog, RenameDialog, DeleteConfirmDialog } from "@/components/FileDialogs";
10
14
  import { TreeSkeleton, ContentSkeleton, InlineError } from "@/components/Skeleton";
11
15
  import type { TiptapSidecarStyles } from "@/components/MarkdownEditor";
12
16
  import type { SidecarStyles } from "@/lib/csv-utils";
@@ -169,20 +173,28 @@ function TreeNode({
169
173
  depth,
170
174
  selected,
171
175
  onSelect,
176
+ onContextMenu,
172
177
  }: {
173
178
  entry: TreeEntry;
174
179
  depth: number;
175
180
  selected: string | null;
176
181
  onSelect: (path: string) => void;
182
+ onContextMenu?: (e: React.MouseEvent, entry: TreeEntry) => void;
177
183
  }) {
178
184
  const [open, setOpen] = useState(depth === 0);
179
185
  const pl = depth * 12;
180
186
 
187
+ function handleCtx(e: React.MouseEvent) {
188
+ e.preventDefault();
189
+ onContextMenu?.(e, entry);
190
+ }
191
+
181
192
  if (entry.type === "dir") {
182
193
  return (
183
194
  <div>
184
195
  <button
185
196
  onClick={() => setOpen(!open)}
197
+ onContextMenu={handleCtx}
186
198
  className="w-full text-left py-1 px-2 text-sm hover:bg-gray-700 rounded flex items-center gap-1"
187
199
  style={{ paddingLeft: pl }}
188
200
  >
@@ -198,6 +210,7 @@ function TreeNode({
198
210
  depth={depth + 1}
199
211
  selected={selected}
200
212
  onSelect={onSelect}
213
+ onContextMenu={onContextMenu}
201
214
  />
202
215
  ))}
203
216
  </div>
@@ -208,6 +221,7 @@ function TreeNode({
208
221
  return (
209
222
  <button
210
223
  onClick={() => onSelect(entry.path)}
224
+ onContextMenu={handleCtx}
211
225
  className={`w-full text-left py-1 px-2 text-sm rounded flex items-center gap-1 ${
212
226
  isSelected ? "bg-blue-600 text-white" : "hover:bg-gray-700"
213
227
  }`}
@@ -234,6 +248,14 @@ export default function MainPage() {
234
248
  const [fileLoading, setFileLoading] = useState(false);
235
249
  const [editing, setEditing] = useState(false);
236
250
 
251
+ const [ctxMenu, setCtxMenu] = useState<{
252
+ x: number; y: number; entry: TreeEntry;
253
+ } | null>(null);
254
+ const [dialog, setDialog] = useState<{
255
+ type: "create" | "rename" | "delete";
256
+ entry: TreeEntry;
257
+ } | null>(null);
258
+
237
259
  const loadTree = useCallback(() => {
238
260
  setApiState("loading");
239
261
  fetch(`${API_BASE}/tree`)
@@ -325,6 +347,79 @@ export default function MainPage() {
325
347
  setEditing(false);
326
348
  }, [autoSave]);
327
349
 
350
+ const handleTreeContextMenu = useCallback(
351
+ (e: React.MouseEvent, entry: TreeEntry) => {
352
+ if (!canEdit || apiState !== "ready") return;
353
+ setCtxMenu({ x: e.clientX, y: e.clientY, entry });
354
+ },
355
+ [canEdit, apiState],
356
+ );
357
+
358
+ const handleContextAction = useCallback(
359
+ (action: FileAction) => {
360
+ if (!ctxMenu) return;
361
+ setDialog({ type: action, entry: ctxMenu.entry });
362
+ setCtxMenu(null);
363
+ },
364
+ [ctxMenu],
365
+ );
366
+
367
+ const handleCreateFile = useCallback(
368
+ async (fileName: string) => {
369
+ if (!dialog || dialog.type !== "create") return;
370
+ const parentPath = dialog.entry.path;
371
+ const fullPath = `${parentPath}/${fileName}`;
372
+ setDialog(null);
373
+ try {
374
+ await createFileApi(fullPath);
375
+ showToast(`${fileName} を作成しました`, "success");
376
+ loadTree();
377
+ handleFileSelect(fullPath);
378
+ } catch (e) {
379
+ showToast(`作成に失敗しました: ${(e as Error).message}`, "error");
380
+ }
381
+ },
382
+ [dialog, showToast, loadTree, handleFileSelect],
383
+ );
384
+
385
+ const handleDeleteFile = useCallback(async () => {
386
+ if (!dialog || dialog.type !== "delete") return;
387
+ const path = dialog.entry.path;
388
+ setDialog(null);
389
+ try {
390
+ await deleteFileApi(path);
391
+ showToast(`${dialog.entry.name} を削除しました`, "success");
392
+ if (selectedFile === path) {
393
+ setSelectedFile(null);
394
+ setFileContent(null);
395
+ }
396
+ loadTree();
397
+ } catch (e) {
398
+ showToast(`削除に失敗しました: ${(e as Error).message}`, "error");
399
+ }
400
+ }, [dialog, showToast, selectedFile, loadTree]);
401
+
402
+ const handleRenameFile = useCallback(
403
+ async (newName: string) => {
404
+ if (!dialog || dialog.type !== "rename") return;
405
+ const oldPath = dialog.entry.path;
406
+ const parentDir = oldPath.substring(0, oldPath.lastIndexOf("/"));
407
+ const newPath = parentDir ? `${parentDir}/${newName}` : newName;
408
+ setDialog(null);
409
+ try {
410
+ await renameFileApi(oldPath, newPath);
411
+ showToast(`${dialog.entry.name} → ${newName} に変更しました`, "success");
412
+ if (selectedFile === oldPath) {
413
+ handleFileSelect(newPath);
414
+ }
415
+ loadTree();
416
+ } catch (e) {
417
+ showToast(`名前変更に失敗しました: ${(e as Error).message}`, "error");
418
+ }
419
+ },
420
+ [dialog, showToast, selectedFile, loadTree, handleFileSelect],
421
+ );
422
+
328
423
  const ext = fileContent ? getFileExt(fileContent.path) : "";
329
424
 
330
425
  return (
@@ -373,6 +468,7 @@ export default function MainPage() {
373
468
  depth={0}
374
469
  selected={selectedFile}
375
470
  onSelect={handleFileSelect}
471
+ onContextMenu={handleTreeContextMenu}
376
472
  />
377
473
  ))}
378
474
  </div>
@@ -386,6 +482,7 @@ export default function MainPage() {
386
482
  depth={0}
387
483
  selected={selectedFile}
388
484
  onSelect={handleFileSelect}
485
+ onContextMenu={handleTreeContextMenu}
389
486
  />
390
487
  ))}
391
488
  </div>
@@ -480,6 +577,38 @@ export default function MainPage() {
480
577
  onClearError={editSession.clearError}
481
578
  />
482
579
  </main>
580
+
581
+ {ctxMenu && (
582
+ <FileContextMenu
583
+ x={ctxMenu.x}
584
+ y={ctxMenu.y}
585
+ isDir={ctxMenu.entry.type === "dir"}
586
+ onAction={handleContextAction}
587
+ onClose={() => setCtxMenu(null)}
588
+ />
589
+ )}
590
+
591
+ {dialog?.type === "create" && (
592
+ <CreateFileDialog
593
+ parentPath={dialog.entry.path}
594
+ onConfirm={handleCreateFile}
595
+ onCancel={() => setDialog(null)}
596
+ />
597
+ )}
598
+ {dialog?.type === "rename" && (
599
+ <RenameDialog
600
+ currentName={dialog.entry.name}
601
+ onConfirm={handleRenameFile}
602
+ onCancel={() => setDialog(null)}
603
+ />
604
+ )}
605
+ {dialog?.type === "delete" && (
606
+ <DeleteConfirmDialog
607
+ fileName={dialog.entry.name}
608
+ onConfirm={handleDeleteFile}
609
+ onCancel={() => setDialog(null)}
610
+ />
611
+ )}
483
612
  </div>
484
613
  );
485
614
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ripla/godd-mcp",
3
- "version": "0.1.3-canary.21",
3
+ "version": "0.1.3-canary.22",
4
4
  "type": "module",
5
5
  "description": "GoDD (Governance-orchestrated Driven Development) MCP Server - Encrypted prompt distribution via Model Context Protocol",
6
6
  "main": "dist/index.js",