@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.
- package/notes-api/app/routers/files.py +128 -2
- package/notes-api/app/services/github_pr.py +36 -0
- package/notes-api/tests/test_files.py +117 -0
- package/notes-app/src/components/FileContextMenu.test.tsx +51 -0
- package/notes-app/src/components/FileContextMenu.tsx +60 -0
- package/notes-app/src/components/FileDialogs.test.tsx +140 -0
- package/notes-app/src/components/FileDialogs.tsx +174 -0
- package/notes-app/src/lib/api.ts +43 -0
- package/notes-app/src/pages/MainPage.tsx +129 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/notes-app/src/lib/api.ts
CHANGED
|
@@ -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.
|
|
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",
|