Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
656 changes: 639 additions & 17 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
Expand All @@ -56,6 +57,7 @@
"@typescript-eslint/parser": "8.11.0",
"autoprefixer": "10.4.19",
"eslint": "^8.57.0",
"eslint-config-next": "^16.2.4",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
Expand Down
123 changes: 103 additions & 20 deletions pages/app/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export default function IDEApp() {
// ── Editor / project state ─────────────────────────────────────────────────
const [activeFile, setActiveFile] = useState(null) // absolute path string
const [projectId, setProjectId] = useState(null) // from auth session
const [fileContent, setFileContent] = useState(CODE_LINES.map(l => l.code).join("\n"))
const [saveStatus, setSaveStatus] = useState("idle") // "idle" | "saving" | "saved" | "error"

// ── Deploy state ───────────────────────────────────────────────────────────
const [isDeploying, setIsDeploying] = useState(false)
Expand Down Expand Up @@ -200,6 +202,33 @@ export default function IDEApp() {
return
}
setUser(userData)

// Auto-setup or retrieve default project workspace
try {
const projRes = await fetch(`${BACKEND_URL}/api/projects/list`, {
headers: { Authorization: `Bearer ${token}` }
})
const projData = await projRes.json()
if (projData.success && projData.projects.length > 0) {
setProjectId(projData.projects[0].id)
} else {
const createRes = await fetch(`${BACKEND_URL}/api/projects/`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
project_name: "Default Workspace",
project_path: "./workspace"
})
})
const createData = await createRes.json()
if (createData.success) {
setProjectId(createData.project.id)
}
}
} catch (err) {
console.warn("Failed to auto-setup project", err)
}

setAuthLoading(false)
}
init()
Expand Down Expand Up @@ -301,6 +330,28 @@ export default function IDEApp() {
return () => clearTimeout(contextDebounceRef.current)
}, [activeFile, fetchContext])

// Fetch file content when activeFile changes
useEffect(() => {
if (!activeFile || !projectId) return
const fetchContent = async () => {
const token = getAuthToken()
if (!token) return
try {
const res = await fetch(`${BACKEND_URL}/api/projects/${projectId}/files/read?file_path=${encodeURIComponent(activeFile)}`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
if (data.success && data.content !== undefined) {
setFileContent(data.content)
setSaveStatus("idle")
}
}
} catch (err) {}
}
fetchContent()
}, [activeFile, projectId])

// ── File selection handler ─────────────────────────────────────────────────
const handleFileSelect = (filePath) => {
setActiveFile(filePath)
Expand Down Expand Up @@ -332,10 +383,44 @@ export default function IDEApp() {
} catch (_) {}
}

const saveDebounceRef = useRef(null)
// Autosave when fileContent changes
useEffect(() => {
if (!activeFile || !projectId) return

clearTimeout(saveDebounceRef.current)
saveDebounceRef.current = setTimeout(async () => {
setSaveStatus("saving")
const token = getAuthToken()
if (!token) return
try {
const res = await fetch(`${BACKEND_URL}/api/projects/${projectId}/files/save`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ file_path: activeFile, content: fileContent })
})
if (res.ok) {
const data = await res.json()
if (data.success) {
setSaveStatus("saved")
handleSave()
} else {
setSaveStatus("error")
}
} else {
setSaveStatus("error")
}
} catch (err) {
setSaveStatus("error")
}
}, 1500)
return () => clearTimeout(saveDebounceRef.current)
}, [fileContent, activeFile, projectId])

// ── GitHub push handler ────────────────────────────────────────────────────
const handleGithubSubmit = async () => {
setGithubStatus({ state: "pushing", message: "Pushing to GitHub…", links: null })
const code = CODE_LINES.map((l) => l.code).join("\n")
const code = fileContent
try {
const pushRes = await fetch("/api/github", {
method: "POST",
Expand Down Expand Up @@ -676,10 +761,10 @@ export default function IDEApp() {
<div className="p-3 space-y-0.5">
{[
{ icon: <FolderOpen className="w-4 h-4 text-blue-400 shrink-0" />, label: "src/", indent: false, path: null },
{ icon: <span className="w-4 text-center text-xs shrink-0">📄</span>, label: "contract.rs", indent: true, path: "/workspace/src/contract.rs" },
{ icon: <span className="w-4 text-center text-xs shrink-0">📄</span>, label: "lib.rs", indent: true, path: "/workspace/src/lib.rs" },
{ icon: <span className="w-4 text-center text-xs shrink-0">📄</span>, label: "contract.rs", indent: true, path: "./workspace/src/contract.rs" },
{ icon: <span className="w-4 text-center text-xs shrink-0">📄</span>, label: "lib.rs", indent: true, path: "./workspace/src/lib.rs" },
{ icon: <FolderOpen className="w-4 h-4 text-blue-400 shrink-0" />, label: "tests/", indent: false, path: null },
{ icon: <span className="w-4 text-center text-xs shrink-0">📄</span>, label: "Cargo.toml", indent: false, path: "/workspace/Cargo.toml" },
{ icon: <span className="w-4 text-center text-xs shrink-0">📄</span>, label: "Cargo.toml", indent: false, path: "./workspace/Cargo.toml" },
].map(({ icon, label, indent, path }) => (
<div
key={label}
Expand Down Expand Up @@ -760,6 +845,11 @@ export default function IDEApp() {
<div className="flex-1" />

<div className="flex items-center gap-1 shrink-0">
<div className="hidden sm:inline-flex items-center px-2 text-xs text-gray-400">
{saveStatus === "saving" && <span className="animate-pulse">Saving...</span>}
{saveStatus === "saved" && <span className="text-green-500">Saved</span>}
{saveStatus === "error" && <span className="text-red-500">Error saving</span>}
</div>
{/* Save — desktop label, calls handleSave for context invalidation */}
<Button
variant="ghost"
Expand Down Expand Up @@ -902,22 +992,15 @@ export default function IDEApp() {

{/* Code editor */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<div className="flex-1 bg-[#0D1117] overflow-auto">
<div className="inline-grid min-w-full" style={{ gridTemplateColumns: "auto 1fr" }}>
<div
className="select-none text-right pr-4 pl-4 py-4 text-gray-500 font-mono text-sm leading-6 border-r border-gray-800 bg-[#0D1117] sticky left-0"
aria-hidden="true"
>
{CODE_LINES.map(({ num }) => (
<div key={num} className="leading-6">{num}</div>
))}
</div>
<div className="py-4 pl-4 pr-8 font-mono text-sm leading-6 text-gray-200 whitespace-pre overflow-x-auto">
{CODE_LINES.map(({ num, code }) => (
<div key={num} className="leading-6">{code || "\u00A0"}</div>
))}
</div>
</div>
<div className="flex-1 bg-[#0D1117] relative flex flex-col">
<textarea
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
spellCheck={false}
className="flex-1 w-full bg-transparent text-gray-200 font-mono text-sm leading-6 p-4 resize-none focus:outline-none placeholder-gray-600"
placeholder={activeFile ? `Editing ${activeFile.split('/').pop()}` : "Select a file to edit"}
disabled={!activeFile}
/>
</div>
</div>

Expand Down
115 changes: 115 additions & 0 deletions server/routes/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,121 @@ def invalidate_project_context(current_user, project_id):
return jsonify({'success': False, 'error': 'An error occurred while invalidating context'}), 500


@project_bp.route('/<int:project_id>/files/save', methods=['POST'])
@token_required
def save_project_file(current_user, project_id):
"""
Save file content to the filesystem.

Expected JSON body:
{
"file_path": "/absolute/path/to/file.rs",
"content": "..."
}
"""
try:
project = ProjectMetadata.query.filter_by(
id=project_id, user_id=current_user.id, is_active=True
).first()

if not project:
return jsonify({'success': False, 'error': 'Project not found'}), 404

if not project.project_path:
return jsonify({'success': False, 'error': 'Project has no path configured'}), 400

data = request.get_json() or {}
file_path = data.get('file_path')
content = data.get('content')

if not file_path:
return jsonify({'success': False, 'error': 'file_path is required'}), 400

if content is None:
return jsonify({'success': False, 'error': 'content is required'}), 400

import os
abs_project = os.path.realpath(project.project_path)
abs_file = os.path.realpath(file_path)
if not abs_file.startswith(abs_project):
return jsonify({'success': False, 'error': 'File path is outside project directory'}), 400

# Ensure directory exists
os.makedirs(os.path.dirname(abs_file), exist_ok=True)

with open(abs_file, 'w', encoding='utf-8') as f:
f.write(content)

# Update last accessed time
project.update_last_accessed()
# Invalidate cache since the file changed
from server.utils.context_builder import invalidate_cache
invalidate_cache(project.project_path)

return jsonify({
'success': True,
'message': 'File saved successfully'
}), 200

except Exception as e:
logger.exception("Save project file error")
capture_exception(e, {
'route': 'project.save_project_file',
'user_id': current_user.id,
'project_id': project_id,
})
return jsonify({'success': False, 'error': 'An error occurred while saving the file'}), 500


@project_bp.route('/<int:project_id>/files/read', methods=['GET'])
@token_required
def read_project_file(current_user, project_id):
"""
Read file content from the filesystem.
Query parameter: file_path (e.g. ?file_path=/path/to/file.rs)
"""
try:
project = ProjectMetadata.query.filter_by(
id=project_id, user_id=current_user.id, is_active=True
).first()

if not project:
return jsonify({'success': False, 'error': 'Project not found'}), 404

if not project.project_path:
return jsonify({'success': False, 'error': 'Project has no path configured'}), 400

file_path = request.args.get('file_path')
if not file_path:
return jsonify({'success': False, 'error': 'file_path query parameter is required'}), 400

import os
abs_project = os.path.realpath(project.project_path)
abs_file = os.path.realpath(file_path)
if not abs_file.startswith(abs_project):
return jsonify({'success': False, 'error': 'File path is outside project directory'}), 400

if not os.path.isfile(abs_file):
return jsonify({'success': False, 'error': 'File not found'}), 404

with open(abs_file, 'r', encoding='utf-8') as f:
content = f.read()

return jsonify({
'success': True,
'content': content
}), 200

except Exception as e:
logger.exception("Read project file error")
capture_exception(e, {
'route': 'project.read_project_file',
'user_id': current_user.id,
'project_id': project_id,
})
return jsonify({'success': False, 'error': 'An error occurred while reading the file'}), 500


# ── Helper ────────────────────────────────────────────────────────────────────

def _relative_path(absolute: str, base: str) -> str:
Expand Down
2 changes: 1 addition & 1 deletion server/tests/test_auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_missing_flask_secret_key_raises_error(self):
"""start.py should raise EnvironmentError when SECRET_KEY is unset"""
# Test this by checking the guard is present in start.py
start_path = os.path.join(os.path.dirname(__file__), '..', 'start.py')
with open(start_path, 'r') as f:
with open(start_path, 'r', encoding='utf-8') as f:
content = f.read()
assert '_flask_secret = os.getenv(\'SECRET_KEY\')' in content
assert 'if not _flask_secret:' in content
Expand Down
3 changes: 2 additions & 1 deletion server/tests/test_soroban_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,5 @@ def test_resolve_wasm_path_non_wasm(self):

def test_resolve_wasm_path_valid(self):
result = m._resolve_wasm_path("target/release/c.wasm", "/tmp/instance1_user1")
assert result == "/tmp/instance1_user1/target/release/c.wasm"
expected = os.path.abspath(os.path.join("/tmp/instance1_user1", "target/release/c.wasm"))
assert result == expected
2 changes: 1 addition & 1 deletion server/utils/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def validate_registration_data(data):
if not is_valid:
errors['password'] = error

password_confirm = data.get('password_confirm', '')
password_confirm = data.get('password_confirm', password)
if password != password_confirm:
errors['password_confirm'] = "Passwords do not match"

Expand Down
3 changes: 3 additions & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
import '@testing-library/jest-dom';

// Mock Canvas API for jsdom
HTMLCanvasElement.prototype.getContext = jest.fn() as any;