8c7affc933
Fixed import paths for 43 components moved into features/ directories. Key fixes: - api/, types/, hooks/, state/, utils/ imports need ../../../ from features/*/ - components/ imports need ../../ from features/*/ - Cross-feature imports use relative paths (e.g., ../tool/tools-bottom-sheet) - app-shell.tsx updated to import from features/ subdirectories Frontend typecheck now passes except for one pre-existing error: xterm-addon-webgl missing type declarations. Quality gates: ruff passed on backend, py_compile passed on all backend files.
242 lines
6.3 KiB
TypeScript
242 lines
6.3 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { apiClient } from "../../../api/client";
|
|
import { useAuth } from "../../../state/auth";
|
|
import { CodeEditor } from "../../code-editor";
|
|
import { CommitDialog } from "./commit-dialog";
|
|
import { Icon } from "../../icon";
|
|
import { SyntaxHighlighter } from "../../syntax-highlighter";
|
|
import { detectLanguage } from "../../../utils/language";
|
|
|
|
interface FileEditorProps {
|
|
projectId: string;
|
|
repoId: string;
|
|
}
|
|
|
|
export const FileEditor: React.FC<FileEditorProps> = ({
|
|
projectId,
|
|
repoId,
|
|
}) => {
|
|
const [searchParams] = useSearchParams();
|
|
const { user } = useAuth();
|
|
|
|
const [mode, setMode] = useState<"view" | "edit">("view");
|
|
const [content, setContent] = useState(">");
|
|
const [originalContent, setOriginalContent] = useState(">");
|
|
const [language, setLanguage] = useState("plaintext");
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showCommitDialog, setShowCommitDialog] = useState(false);
|
|
const [isBinary, setIsBinary] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const branch = searchParams.get("branch") || "main";
|
|
const filePath = searchParams.get("file");
|
|
|
|
const loadFile = useCallback(async () => {
|
|
if (!filePath) {
|
|
setContent("");
|
|
setOriginalContent("");
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const response = await apiClient.get(
|
|
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
|
{
|
|
params: {
|
|
branch,
|
|
path: filePath,
|
|
},
|
|
}
|
|
);
|
|
const data = response.data;
|
|
if (data.is_binary) {
|
|
setIsBinary(true);
|
|
setContent("Binary file - cannot display");
|
|
setOriginalContent("");
|
|
} else {
|
|
setIsBinary(false);
|
|
setContent(data.content);
|
|
setOriginalContent(data.content);
|
|
setLanguage(detectLanguage(filePath));
|
|
}
|
|
} catch {
|
|
setError("Failed to load file");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectId, repoId, branch, filePath]);
|
|
|
|
useEffect(() => {
|
|
void loadFile();
|
|
}, [loadFile]);
|
|
|
|
const handleEdit = () => {
|
|
if (isBinary) return;
|
|
setMode("edit");
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setContent(originalContent);
|
|
setMode("view");
|
|
setShowCommitDialog(false);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (content === originalContent) {
|
|
setMode("view");
|
|
return;
|
|
}
|
|
setShowCommitDialog(true);
|
|
};
|
|
|
|
const handleCommit = async (message: string) => {
|
|
if (!filePath || !user) return;
|
|
|
|
setSaving(true);
|
|
try {
|
|
await apiClient.post(
|
|
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
|
{
|
|
path: filePath,
|
|
branch,
|
|
content,
|
|
commit_message: message,
|
|
author_name: user.name || "User",
|
|
author_email: user.email || "user@example.com",
|
|
}
|
|
);
|
|
setOriginalContent(content);
|
|
setMode("view");
|
|
setShowCommitDialog(false);
|
|
} catch {
|
|
setError("Failed to save changes");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
|
|
e.preventDefault();
|
|
if (mode === "view" && !isBinary) {
|
|
handleEdit();
|
|
} else if (mode === "edit") {
|
|
handleCancel();
|
|
}
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
if (mode === "edit") {
|
|
handleSave();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [mode, isBinary, content, originalContent]);
|
|
|
|
if (!filePath) {
|
|
return (
|
|
<div className="file-viewer-empty">
|
|
<p className="muted">Select a file to view its contents</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) return <p className="muted">Loading file...</p>;
|
|
if (error) return <p className="error-text">{error}</p>;
|
|
|
|
return (
|
|
<div className="file-editor">
|
|
<div className="file-editor-toolbar">
|
|
<div className="file-breadcrumbs">
|
|
{filePath.split("/").map((part, i, arr) => (
|
|
<span key={i}>
|
|
{part}
|
|
{i < arr.length - 1 && (
|
|
<span className="breadcrumb-sep">/</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="file-actions">
|
|
{mode === "view" && !isBinary && (
|
|
<button
|
|
className="btn-primary"
|
|
onClick={handleEdit}
|
|
type="button"
|
|
>
|
|
<Icon name="edit" size="sm" />
|
|
Edit
|
|
</button>
|
|
)}
|
|
{mode === "edit" && (
|
|
<>
|
|
<button
|
|
className="btn-primary"
|
|
onClick={handleSave}
|
|
disabled={content === originalContent || saving}
|
|
type="button"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<Icon name="loading" size="sm" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Icon name="save" size="sm" />
|
|
Save
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
className="btn-secondary"
|
|
onClick={handleCancel}
|
|
type="button"
|
|
>
|
|
<Icon name="cancel" size="sm" />
|
|
Cancel
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="file-editor-content">
|
|
{mode === "view" && (
|
|
<SyntaxHighlighter
|
|
code={content}
|
|
language={language}
|
|
showLineNumbers={!isBinary}
|
|
/>
|
|
)}
|
|
|
|
{mode === "edit" && (
|
|
<CodeEditor
|
|
value={content}
|
|
onChange={setContent}
|
|
language={language}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<CommitDialog
|
|
isOpen={showCommitDialog}
|
|
filePath={filePath}
|
|
originalContent={originalContent}
|
|
newContent={content}
|
|
onCommit={handleCommit}
|
|
onCancel={() => setShowCommitDialog(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|