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
45 changes: 41 additions & 4 deletions flocks/server/routes/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
# Upload size limit read at request time so env-var changes take effect
# without restarting the process.
_DEFAULT_MAX_UPLOAD_MB = 100
_DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024
_ALLOWED_UPLOAD_EXTENSIONS = {
".txt", ".md", ".json", ".yaml", ".yml", ".xml", ".csv",
".pdf", ".doc", ".docx", ".html", ".htm", ".ppt", ".pptx", ".xls", ".xlsx",
Expand All @@ -68,6 +69,10 @@ def _max_upload_bytes() -> int:
return int(os.getenv("FLOCKS_WORKSPACE_MAX_UPLOAD_MB", str(_DEFAULT_MAX_UPLOAD_MB))) * 1024 * 1024


def _max_read_bytes() -> int:
return int(os.getenv("FLOCKS_WORKSPACE_MAX_READ_BYTES", str(_DEFAULT_MAX_READ_BYTES)))


# ─── helpers ────────────────────────────────────────────────────────────────

def _get_manager() -> WorkspaceManager:
Expand Down Expand Up @@ -144,6 +149,16 @@ def _dir_stats_sync(root: Path):
return file_count, dir_count, total_size


def _read_text_preview_sync(path: Path, max_bytes: int) -> tuple[str, bool]:
"""Read at most ``max_bytes`` from a text file for safe preview."""
with path.open("rb") as handle:
data = handle.read(max_bytes + 1)
truncated = len(data) > max_bytes
if truncated:
data = data[:max_bytes]
return data.decode("utf-8", errors="replace"), truncated


# ─── directory operations ───────────────────────────────────────────────────

@router.get("/tree", response_model=WorkspaceNode, summary="List directory tree")
Expand Down Expand Up @@ -314,11 +329,22 @@ async def read_file(
status_code=400,
detail="Binary file — use /download endpoint instead",
)
max_read_bytes = _max_read_bytes()
try:
content = target.read_text(encoding="utf-8", errors="replace")
content, truncated = await asyncio.to_thread(
_read_text_preview_sync,
target,
max_read_bytes,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"path": path, "content": content}
return {
"path": path,
"content": content,
"truncated": truncated,
"size": target.stat().st_size,
"preview_limit_bytes": max_read_bytes,
}


class FileWriteRequest(BaseModel):
Expand Down Expand Up @@ -470,11 +496,22 @@ async def read_memory_file(
raise HTTPException(status_code=404, detail=f"Memory file not found: {path}")
if not target.is_file():
raise HTTPException(status_code=400, detail=f"Not a file: {path}")
max_read_bytes = _max_read_bytes()
try:
content = target.read_text(encoding="utf-8", errors="replace")
content, truncated = await asyncio.to_thread(
_read_text_preview_sync,
target,
max_read_bytes,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"path": path, "content": content}
return {
"path": path,
"content": content,
"truncated": truncated,
"size": target.stat().st_size,
"preview_limit_bytes": max_read_bytes,
}


# ─── stats ──────────────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion flocks/workspace/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
# Note: dotfiles like .gitignore have suffix='' in Python, so they are NOT
# matched here; they will fall through to the binary-file path (download only).
TEXT_EXTENSIONS = {
".md", ".txt", ".log", ".json", ".yaml", ".yml",
".md", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml",
".toml", ".ini", ".cfg", ".py", ".js", ".ts",
".sh", ".bash", ".csv", ".xml", ".html", ".css",
".tsx", ".jsx", ".env",
Expand Down
1 change: 1 addition & 0 deletions tests/workspace/test_workspace_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ class TestIsTextFile:
("notes.txt", True),
("server.log", True),
("config.json", True),
("events.jsonl", True),
("settings.yaml", True),
("settings.yml", True),
("pyproject.toml", True),
Expand Down
36 changes: 36 additions & 0 deletions tests/workspace/test_workspace_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,29 @@ def test_read_text_file(self, workspace_client):
assert data["path"] == "outputs/note.md"
assert data["content"] == "# Hello\nWorld"

def test_read_jsonl_file(self, workspace_client):
ws = _ws(workspace_client)
(ws / "outputs" / "events.jsonl").write_text('{"id": 1}\n{"id": 2}\n')
r = _client(workspace_client).get("/api/workspace/file?path=outputs/events.jsonl")
assert r.status_code == 200
data = r.json()
assert data["path"] == "outputs/events.jsonl"
assert data["content"] == '{"id": 1}\n{"id": 2}\n'
assert data["truncated"] is False

def test_read_large_text_file_returns_truncated_preview(self, workspace_client, monkeypatch):
monkeypatch.setenv("FLOCKS_WORKSPACE_MAX_READ_BYTES", "10")
ws = _ws(workspace_client)
(ws / "outputs" / "large.jsonl").write_text("0123456789ABCDEF", encoding="utf-8")
r = _client(workspace_client).get("/api/workspace/file?path=outputs/large.jsonl")
assert r.status_code == 200
data = r.json()
assert data["path"] == "outputs/large.jsonl"
assert data["content"] == "0123456789"
assert data["truncated"] is True
assert data["size"] == 16
assert data["preview_limit_bytes"] == 10

def test_read_nonexistent_returns_404(self, workspace_client):
r = _client(workspace_client).get("/api/workspace/file?path=ghost.txt")
assert r.status_code == 404
Expand Down Expand Up @@ -545,6 +568,19 @@ def test_read_memory_file(self, workspace_client):
data = r.json()
assert data["path"] == "MEMORY.md"
assert "Key facts" in data["content"]
assert data["truncated"] is False

def test_read_large_memory_file_returns_truncated_preview(self, workspace_client, monkeypatch):
monkeypatch.setenv("FLOCKS_WORKSPACE_MAX_READ_BYTES", "8")
mem = _mem(workspace_client)
(mem / "large.md").write_text("abcdefghijk", encoding="utf-8")
r = _client(workspace_client).get("/api/workspace/memory/file?path=large.md")
assert r.status_code == 200
data = r.json()
assert data["content"] == "abcdefgh"
assert data["truncated"] is True
assert data["size"] == 11
assert data["preview_limit_bytes"] == 8

def test_read_memory_nonexistent_returns_404(self, workspace_client):
r = _client(workspace_client).get("/api/workspace/memory/file?path=ghost.md")
Expand Down
14 changes: 11 additions & 3 deletions webui/src/api/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export interface UploadResult {
error?: string;
}

export interface WorkspaceFileContentResponse {
path: string;
content: string;
truncated?: boolean;
size?: number;
preview_limit_bytes?: number;
}

export type UploadPurpose = 'chat';

// ─── API ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -66,7 +74,7 @@ export const workspaceAPI = {
},

readFile: (path: string) =>
client.get<{ path: string; content: string }>('/api/workspace/file', { params: { path } }),
client.get<WorkspaceFileContentResponse>('/api/workspace/file', { params: { path } }),

writeFile: (path: string, content: string) =>
client.put<{ path: string; written: boolean }>('/api/workspace/file', { path, content }),
Expand All @@ -92,7 +100,7 @@ export const workspaceAPI = {
client.get<WorkspaceNode[]>('/api/workspace/memory/list'),

readMemoryFile: (path: string) =>
client.get<{ path: string; content: string }>('/api/workspace/memory/file', { params: { path } }),
client.get<WorkspaceFileContentResponse>('/api/workspace/memory/file', { params: { path } }),

// Stats
stats: () =>
Expand All @@ -118,7 +126,7 @@ export function fileIcon(node: WorkspaceNode): string {
if (node.type === 'directory') return '📁';
const ext = node.name.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
md: '📝', txt: '📄', log: '📋', json: '🔧', yaml: '🔧', yml: '🔧',
md: '📝', txt: '📄', log: '📋', json: '🔧', jsonl: '🔧', yaml: '🔧', yml: '🔧',
py: '🐍', js: '🟨', ts: '🔷', tsx: '🔷', jsx: '🟨',
sh: '⚙️', bash: '⚙️', csv: '📊', pdf: '📕', png: '🖼️',
jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', zip: '🗜️', tar: '🗜️', gz: '🗜️',
Expand Down
1 change: 1 addition & 0 deletions webui/src/locales/en-US/workspace.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"close": "Close",
"uploading": "Uploading...",
"binaryPreview": "Binary files cannot be previewed",
"truncatedPreview": "This file is large, so only the first {{limit}} is previewed. Inline editing is disabled to avoid saving truncated content; download the file for the full contents.",
"downloadFile": "Download File",
"toast": {
"loadDirFailed": "Failed to load directory",
Expand Down
1 change: 1 addition & 0 deletions webui/src/locales/zh-CN/workspace.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"close": "关闭",
"uploading": "上传中...",
"binaryPreview": "二进制文件无法预览",
"truncatedPreview": "文件较大,当前仅预览前 {{limit}} 内容。为避免误保存截断内容,已禁用在线编辑;如需完整内容请下载文件。",
"downloadFile": "下载文件",
"toast": {
"loadDirFailed": "加载目录失败",
Expand Down
Loading