cowork-dash 0.1.6__py3-none-any.whl → 0.1.7__py3-none-any.whl
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.
- cowork_dash/agent.py +65 -24
- cowork_dash/app.py +602 -333
- cowork_dash/assets/styles.css +12 -0
- cowork_dash/backends.py +435 -0
- cowork_dash/canvas.py +96 -37
- cowork_dash/cli.py +23 -12
- cowork_dash/components.py +0 -1
- cowork_dash/config.py +21 -0
- cowork_dash/file_utils.py +147 -18
- cowork_dash/layout.py +9 -2
- cowork_dash/tools.py +196 -7
- cowork_dash/virtual_fs.py +468 -0
- {cowork_dash-0.1.6.dist-info → cowork_dash-0.1.7.dist-info}/METADATA +1 -1
- cowork_dash-0.1.7.dist-info/RECORD +22 -0
- cowork_dash-0.1.6.dist-info/RECORD +0 -20
- {cowork_dash-0.1.6.dist-info → cowork_dash-0.1.7.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.6.dist-info → cowork_dash-0.1.7.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.6.dist-info → cowork_dash-0.1.7.dist-info}/licenses/LICENSE +0 -0
cowork_dash/canvas.py
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
"""Canvas utilities for parsing, exporting, and loading canvas objects.
|
|
1
|
+
"""Canvas utilities for parsing, exporting, and loading canvas objects.
|
|
2
|
+
|
|
3
|
+
Supports both physical filesystem (Path) and virtual filesystem (VirtualFilesystem)
|
|
4
|
+
for session isolation in multi-user deployments.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
import io
|
|
4
8
|
import json
|
|
@@ -6,9 +10,24 @@ import base64
|
|
|
6
10
|
import re
|
|
7
11
|
import uuid
|
|
8
12
|
from pathlib import Path
|
|
9
|
-
from typing import Any, Dict, List, Optional
|
|
13
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
14
|
from datetime import datetime
|
|
11
15
|
|
|
16
|
+
from .virtual_fs import VirtualFilesystem, VirtualPath
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Type alias for paths that work with both physical and virtual filesystems
|
|
20
|
+
AnyPath = Union[Path, VirtualPath]
|
|
21
|
+
AnyRoot = Union[Path, VirtualFilesystem]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_path(root: AnyRoot, path: str = "") -> AnyPath:
|
|
25
|
+
"""Get a path object from root, handling both Path and VirtualFilesystem."""
|
|
26
|
+
if isinstance(root, VirtualFilesystem):
|
|
27
|
+
return root.path(path) if path else root.root
|
|
28
|
+
else:
|
|
29
|
+
return root / path if path else root
|
|
30
|
+
|
|
12
31
|
|
|
13
32
|
def generate_canvas_id() -> str:
|
|
14
33
|
"""Generate a unique ID for a canvas item."""
|
|
@@ -17,7 +36,7 @@ def generate_canvas_id() -> str:
|
|
|
17
36
|
|
|
18
37
|
def parse_canvas_object(
|
|
19
38
|
obj: Any,
|
|
20
|
-
workspace_root:
|
|
39
|
+
workspace_root: AnyRoot,
|
|
21
40
|
title: Optional[str] = None,
|
|
22
41
|
item_id: Optional[str] = None
|
|
23
42
|
) -> Dict[str, Any]:
|
|
@@ -25,7 +44,7 @@ def parse_canvas_object(
|
|
|
25
44
|
|
|
26
45
|
Args:
|
|
27
46
|
obj: The Python object to parse (DataFrame, Figure, Image, str, etc.)
|
|
28
|
-
workspace_root: Path to the workspace root directory
|
|
47
|
+
workspace_root: Path to the workspace root directory (Path or VirtualFilesystem)
|
|
29
48
|
title: Optional title for the canvas item
|
|
30
49
|
item_id: Optional ID for the canvas item (auto-generated if not provided)
|
|
31
50
|
|
|
@@ -53,7 +72,7 @@ def parse_canvas_object(
|
|
|
53
72
|
return result
|
|
54
73
|
|
|
55
74
|
# Ensure .canvas directory exists
|
|
56
|
-
canvas_dir = workspace_root
|
|
75
|
+
canvas_dir = _get_path(workspace_root, ".canvas")
|
|
57
76
|
canvas_dir.mkdir(exist_ok=True)
|
|
58
77
|
|
|
59
78
|
# Pandas DataFrame - keep inline
|
|
@@ -71,15 +90,19 @@ def parse_canvas_object(
|
|
|
71
90
|
filename = f"matplotlib_{timestamp}.png"
|
|
72
91
|
filepath = canvas_dir / filename
|
|
73
92
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# Also store base64 for in-memory rendering
|
|
93
|
+
# Save to buffer first, then to file
|
|
77
94
|
buf = io.BytesIO()
|
|
78
95
|
obj.savefig(buf, format='png', bbox_inches='tight', dpi=100)
|
|
79
96
|
buf.seek(0)
|
|
80
|
-
|
|
97
|
+
img_data = buf.read()
|
|
81
98
|
buf.close()
|
|
82
99
|
|
|
100
|
+
# Write to file (virtual or physical)
|
|
101
|
+
filepath.write_bytes(img_data)
|
|
102
|
+
|
|
103
|
+
# Also store base64 for in-memory rendering
|
|
104
|
+
img_base64 = base64.b64encode(img_data).decode('utf-8')
|
|
105
|
+
|
|
83
106
|
return add_metadata({
|
|
84
107
|
"type": "matplotlib",
|
|
85
108
|
"file": filename, # Relative to .canvas/ directory where canvas.md lives
|
|
@@ -107,15 +130,19 @@ def parse_canvas_object(
|
|
|
107
130
|
filename = f"image_{timestamp}.png"
|
|
108
131
|
filepath = canvas_dir / filename
|
|
109
132
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
# Also store base64 for in-memory rendering
|
|
133
|
+
# Save to buffer first
|
|
113
134
|
buf = io.BytesIO()
|
|
114
135
|
obj.save(buf, format='PNG')
|
|
115
136
|
buf.seek(0)
|
|
116
|
-
|
|
137
|
+
img_data = buf.read()
|
|
117
138
|
buf.close()
|
|
118
139
|
|
|
140
|
+
# Write to file (virtual or physical)
|
|
141
|
+
filepath.write_bytes(img_data)
|
|
142
|
+
|
|
143
|
+
# Also store base64 for in-memory rendering
|
|
144
|
+
img_base64 = base64.b64encode(img_data).decode('utf-8')
|
|
145
|
+
|
|
119
146
|
return add_metadata({
|
|
120
147
|
"type": "image",
|
|
121
148
|
"file": filename, # Relative to .canvas/ directory where canvas.md lives
|
|
@@ -162,14 +189,29 @@ def parse_canvas_object(
|
|
|
162
189
|
})
|
|
163
190
|
|
|
164
191
|
|
|
165
|
-
def export_canvas_to_markdown(
|
|
166
|
-
|
|
192
|
+
def export_canvas_to_markdown(
|
|
193
|
+
canvas_items: List[Dict],
|
|
194
|
+
workspace_root: AnyRoot,
|
|
195
|
+
output_path: str = None
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Export canvas to markdown file with file references and metadata.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
canvas_items: List of parsed canvas items
|
|
201
|
+
workspace_root: Path to the workspace root directory (Path or VirtualFilesystem)
|
|
202
|
+
output_path: Optional custom output path (relative to workspace_root/.canvas/)
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Path to the output file
|
|
206
|
+
"""
|
|
167
207
|
# Ensure .canvas directory exists
|
|
168
|
-
canvas_dir = workspace_root
|
|
208
|
+
canvas_dir = _get_path(workspace_root, ".canvas")
|
|
169
209
|
canvas_dir.mkdir(exist_ok=True)
|
|
170
210
|
|
|
171
211
|
if not output_path:
|
|
172
|
-
|
|
212
|
+
output_file = canvas_dir / "canvas.md"
|
|
213
|
+
else:
|
|
214
|
+
output_file = _get_path(workspace_root, output_path)
|
|
173
215
|
|
|
174
216
|
lines = [
|
|
175
217
|
"# Canvas Export",
|
|
@@ -222,24 +264,37 @@ def export_canvas_to_markdown(canvas_items: List[Dict], workspace_root: Path, ou
|
|
|
222
264
|
lines.append(f"\n```json\n{json.dumps(parsed.get('data'), indent=2)}\n```\n")
|
|
223
265
|
|
|
224
266
|
# Write to file
|
|
225
|
-
output_file = Path(output_path)
|
|
226
267
|
output_file.write_text("\n".join(lines))
|
|
227
268
|
return str(output_file)
|
|
228
269
|
|
|
229
270
|
|
|
230
|
-
def load_canvas_from_markdown(
|
|
231
|
-
|
|
271
|
+
def load_canvas_from_markdown(
|
|
272
|
+
workspace_root: AnyRoot,
|
|
273
|
+
markdown_path: str = None
|
|
274
|
+
) -> List[Dict]:
|
|
275
|
+
"""Load canvas from markdown file and referenced assets, preserving metadata.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
workspace_root: Path to the workspace root directory (Path or VirtualFilesystem)
|
|
279
|
+
markdown_path: Optional custom markdown file path
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of parsed canvas items
|
|
283
|
+
"""
|
|
232
284
|
if not markdown_path:
|
|
233
|
-
|
|
285
|
+
canvas_md = _get_path(workspace_root, ".canvas/canvas.md")
|
|
234
286
|
else:
|
|
235
|
-
|
|
287
|
+
canvas_md = _get_path(workspace_root, markdown_path)
|
|
236
288
|
|
|
237
|
-
if not
|
|
289
|
+
if not canvas_md.exists():
|
|
238
290
|
return []
|
|
239
291
|
|
|
240
|
-
content =
|
|
292
|
+
content = canvas_md.read_text()
|
|
241
293
|
canvas_items = []
|
|
242
294
|
|
|
295
|
+
# Get parent directory for loading referenced files
|
|
296
|
+
canvas_dir = canvas_md.parent
|
|
297
|
+
|
|
243
298
|
# First, find all metadata comments to get item boundaries and metadata
|
|
244
299
|
metadata_pattern = r'<!-- canvas-item: ({.*?}) -->'
|
|
245
300
|
metadata_matches = list(re.finditer(metadata_pattern, content))
|
|
@@ -260,17 +315,21 @@ def load_canvas_from_markdown(workspace_root: Path, markdown_path: str = None) -
|
|
|
260
315
|
end = len(content)
|
|
261
316
|
|
|
262
317
|
item_content = content[start:end].strip()
|
|
263
|
-
item = _parse_item_content(item_content, metadata,
|
|
318
|
+
item = _parse_item_content(item_content, metadata, canvas_dir)
|
|
264
319
|
if item:
|
|
265
320
|
canvas_items.append(item)
|
|
266
321
|
else:
|
|
267
322
|
# Fallback: legacy parsing without metadata (backwards compatibility)
|
|
268
|
-
canvas_items = _parse_legacy_canvas(content,
|
|
323
|
+
canvas_items = _parse_legacy_canvas(content, canvas_dir)
|
|
269
324
|
|
|
270
325
|
return canvas_items
|
|
271
326
|
|
|
272
327
|
|
|
273
|
-
def _parse_item_content(
|
|
328
|
+
def _parse_item_content(
|
|
329
|
+
content: str,
|
|
330
|
+
metadata: Dict,
|
|
331
|
+
canvas_dir: AnyPath
|
|
332
|
+
) -> Optional[Dict]:
|
|
274
333
|
"""Parse a single item's content given its metadata."""
|
|
275
334
|
item_type = metadata.get("type", "markdown")
|
|
276
335
|
item = {
|
|
@@ -297,7 +356,7 @@ def _parse_item_content(content: str, metadata: Dict, markdown_path: Path) -> Op
|
|
|
297
356
|
match = re.search(r'```plotly\s*\n([^\n]+)\n```', content)
|
|
298
357
|
if match:
|
|
299
358
|
file_ref = match.group(1).strip()
|
|
300
|
-
file_path =
|
|
359
|
+
file_path = canvas_dir / file_ref
|
|
301
360
|
if file_path.exists():
|
|
302
361
|
item["file"] = file_ref
|
|
303
362
|
item["data"] = json.loads(file_path.read_text())
|
|
@@ -308,10 +367,10 @@ def _parse_item_content(content: str, metadata: Dict, markdown_path: Path) -> Op
|
|
|
308
367
|
if match:
|
|
309
368
|
file_ref = match.group(1)
|
|
310
369
|
if not file_ref.startswith('data:'):
|
|
311
|
-
file_path =
|
|
370
|
+
file_path = canvas_dir / file_ref
|
|
312
371
|
if file_path.exists():
|
|
313
|
-
|
|
314
|
-
|
|
372
|
+
img_data = file_path.read_bytes()
|
|
373
|
+
item["data"] = base64.b64encode(img_data).decode('utf-8')
|
|
315
374
|
item["file"] = file_ref
|
|
316
375
|
item["type"] = "image" # Normalize type
|
|
317
376
|
return item
|
|
@@ -332,7 +391,7 @@ def _parse_item_content(content: str, metadata: Dict, markdown_path: Path) -> Op
|
|
|
332
391
|
return None
|
|
333
392
|
|
|
334
393
|
|
|
335
|
-
def _parse_legacy_canvas(content: str,
|
|
394
|
+
def _parse_legacy_canvas(content: str, canvas_dir: AnyPath) -> List[Dict]:
|
|
336
395
|
"""Parse canvas without metadata comments (legacy format)."""
|
|
337
396
|
canvas_items = []
|
|
338
397
|
code_blocks = []
|
|
@@ -399,17 +458,17 @@ def _parse_legacy_canvas(content: str, markdown_path: Path) -> List[Dict]:
|
|
|
399
458
|
item["data"] = block['content']
|
|
400
459
|
canvas_items.append(item)
|
|
401
460
|
elif block['type'] == 'plotly_file':
|
|
402
|
-
file_path =
|
|
461
|
+
file_path = canvas_dir / block['content']
|
|
403
462
|
if file_path.exists():
|
|
404
463
|
item["type"] = "plotly"
|
|
405
464
|
item["file"] = block['content']
|
|
406
465
|
item["data"] = json.loads(file_path.read_text())
|
|
407
466
|
canvas_items.append(item)
|
|
408
467
|
elif block['type'] == 'image_file':
|
|
409
|
-
file_path =
|
|
468
|
+
file_path = canvas_dir / block['content']
|
|
410
469
|
if file_path.exists():
|
|
411
|
-
|
|
412
|
-
|
|
470
|
+
img_data = file_path.read_bytes()
|
|
471
|
+
item["data"] = base64.b64encode(img_data).decode('utf-8')
|
|
413
472
|
item["type"] = "image"
|
|
414
473
|
item["file"] = block['content']
|
|
415
474
|
canvas_items.append(item)
|
|
@@ -429,4 +488,4 @@ def _parse_legacy_canvas(content: str, markdown_path: Path) -> List[Dict]:
|
|
|
429
488
|
"data": remaining
|
|
430
489
|
})
|
|
431
490
|
|
|
432
|
-
return canvas_items
|
|
491
|
+
return canvas_items
|
cowork_dash/cli.py
CHANGED
|
@@ -126,21 +126,21 @@ cowork-dash run --help
|
|
|
126
126
|
"""
|
|
127
127
|
(project_dir / "README.md").write_text(readme)
|
|
128
128
|
|
|
129
|
-
print(
|
|
130
|
-
print(
|
|
131
|
-
print(
|
|
132
|
-
print(
|
|
133
|
-
print(
|
|
134
|
-
print(
|
|
129
|
+
print("✓ Created project structure")
|
|
130
|
+
print("✓ Created config.py")
|
|
131
|
+
print("✓ Created workspace/")
|
|
132
|
+
print("✓ Created .env.example")
|
|
133
|
+
print("✓ Created .gitignore")
|
|
134
|
+
print("✓ Created README.md")
|
|
135
135
|
print(f"\n{'='*50}")
|
|
136
136
|
print(f"🎉 Project '{name}' created successfully!")
|
|
137
137
|
print(f"{'='*50}\n")
|
|
138
|
-
print(
|
|
138
|
+
print("Next steps:")
|
|
139
139
|
print(f" 1. cd {name}")
|
|
140
|
-
print(
|
|
141
|
-
print(
|
|
142
|
-
print(
|
|
143
|
-
print(
|
|
140
|
+
print(" 2. cp .env.example .env # If using DeepAgents")
|
|
141
|
+
print(" 3. Edit .env and add your ANTHROPIC_API_KEY")
|
|
142
|
+
print(" 4. Edit config.py to customize your agent")
|
|
143
|
+
print(" 5. cowork-dash run")
|
|
144
144
|
print()
|
|
145
145
|
|
|
146
146
|
return 0
|
|
@@ -151,6 +151,10 @@ def run_app_cli(args):
|
|
|
151
151
|
# Import here to avoid loading Dash when just running init
|
|
152
152
|
from .app import run_app
|
|
153
153
|
|
|
154
|
+
# Only pass virtual_fs if explicitly set via --virtual-fs flag
|
|
155
|
+
# Otherwise pass None to let env var / config take precedence
|
|
156
|
+
virtual_fs = True if args.virtual_fs else None
|
|
157
|
+
|
|
154
158
|
return run_app(
|
|
155
159
|
workspace=args.workspace,
|
|
156
160
|
agent_spec=args.agent,
|
|
@@ -159,7 +163,8 @@ def run_app_cli(args):
|
|
|
159
163
|
debug=args.debug,
|
|
160
164
|
title=args.title,
|
|
161
165
|
welcome_message=args.welcome_message,
|
|
162
|
-
config_file=args.config
|
|
166
|
+
config_file=args.config,
|
|
167
|
+
virtual_fs=virtual_fs
|
|
163
168
|
)
|
|
164
169
|
|
|
165
170
|
|
|
@@ -259,6 +264,12 @@ For more help: https://github.com/dkedar7/cowork-dash
|
|
|
259
264
|
dest="welcome_message",
|
|
260
265
|
help="Welcome message shown on startup (supports markdown)"
|
|
261
266
|
)
|
|
267
|
+
run_parser.add_argument(
|
|
268
|
+
"--virtual-fs",
|
|
269
|
+
action="store_true",
|
|
270
|
+
dest="virtual_fs",
|
|
271
|
+
help="Use in-memory virtual filesystem (ephemeral, for multi-user isolation)"
|
|
272
|
+
)
|
|
262
273
|
|
|
263
274
|
# Parse arguments
|
|
264
275
|
args = parser.parse_args()
|
cowork_dash/components.py
CHANGED
|
@@ -335,7 +335,6 @@ def format_interrupt(interrupt_data: Dict, colors: Dict):
|
|
|
335
335
|
# Show action requests if any
|
|
336
336
|
if action_requests:
|
|
337
337
|
for i, action in enumerate(action_requests):
|
|
338
|
-
action_type = action.get("type", "unknown")
|
|
339
338
|
action_tool = action.get("tool", "")
|
|
340
339
|
action_args = action.get("args", {})
|
|
341
340
|
|
cowork_dash/config.py
CHANGED
|
@@ -107,3 +107,24 @@ _default_welcome = """This is your AI-powered workspace. I can help you write co
|
|
|
107
107
|
|
|
108
108
|
Let's get started!"""
|
|
109
109
|
WELCOME_MESSAGE = get_config("welcome_message", default=_default_welcome)
|
|
110
|
+
|
|
111
|
+
# Virtual filesystem mode (for multi-user deployments)
|
|
112
|
+
# Environment variable: DEEPAGENT_VIRTUAL_FS
|
|
113
|
+
# Accepts: true/1/yes to enable
|
|
114
|
+
# When enabled:
|
|
115
|
+
# - Each browser session gets isolated in-memory file storage
|
|
116
|
+
# - Files, canvas, and uploads are not shared between sessions
|
|
117
|
+
# - All data is ephemeral (cleared when session ends)
|
|
118
|
+
# When disabled (default):
|
|
119
|
+
# - All sessions share the same workspace directory on disk
|
|
120
|
+
# - Files persist on disk
|
|
121
|
+
VIRTUAL_FS = get_config(
|
|
122
|
+
"virtual_fs",
|
|
123
|
+
default=False,
|
|
124
|
+
type_cast=lambda x: str(x).lower() in ("true", "1", "yes")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Session timeout in seconds (only used when VIRTUAL_FS is True)
|
|
128
|
+
# Environment variable: DEEPAGENT_SESSION_TIMEOUT
|
|
129
|
+
# Default: 3600 (1 hour)
|
|
130
|
+
SESSION_TIMEOUT = get_config("session_timeout", default=3600, type_cast=int)
|
cowork_dash/file_utils.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
"""File tree and file operations utilities.
|
|
1
|
+
"""File tree and file operations utilities.
|
|
2
|
+
|
|
3
|
+
Supports both physical filesystem (Path) and virtual filesystem (VirtualFilesystem)
|
|
4
|
+
for session isolation in multi-user deployments.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
import base64
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import List, Dict, Tuple
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import List, Dict, Tuple, Union, Optional
|
|
6
10
|
from dash import html
|
|
7
11
|
|
|
12
|
+
from .virtual_fs import VirtualFilesystem, VirtualPath
|
|
13
|
+
|
|
8
14
|
|
|
9
15
|
TEXT_EXTENSIONS = {
|
|
10
16
|
".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".css", ".json", ".md", ".txt",
|
|
@@ -12,6 +18,10 @@ TEXT_EXTENSIONS = {
|
|
|
12
18
|
".gitignore", ".dockerignore", ".cfg", ".ini", ".conf", ".log"
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
# Type alias for paths that work with both physical and virtual filesystems
|
|
22
|
+
AnyPath = Union[Path, VirtualPath]
|
|
23
|
+
AnyRoot = Union[Path, VirtualFilesystem]
|
|
24
|
+
|
|
15
25
|
|
|
16
26
|
def is_text_file(filename: str) -> bool:
|
|
17
27
|
"""Check if a file can be viewed as text."""
|
|
@@ -19,25 +29,67 @@ def is_text_file(filename: str) -> bool:
|
|
|
19
29
|
return ext in TEXT_EXTENSIONS or ext == ""
|
|
20
30
|
|
|
21
31
|
|
|
22
|
-
def
|
|
32
|
+
def _get_path(root: AnyRoot, path: str = "") -> AnyPath:
|
|
33
|
+
"""Get a path object from root, handling both Path and VirtualFilesystem."""
|
|
34
|
+
if isinstance(root, VirtualFilesystem):
|
|
35
|
+
return root.path(path) if path else root.root
|
|
36
|
+
else:
|
|
37
|
+
return root / path if path else root
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _relative_path(path: AnyPath, root: AnyPath) -> str:
|
|
41
|
+
"""Get relative path string."""
|
|
42
|
+
if isinstance(path, VirtualPath):
|
|
43
|
+
path_str = str(path)
|
|
44
|
+
root_str = str(root)
|
|
45
|
+
if path_str.startswith(root_str):
|
|
46
|
+
rel = path_str[len(root_str):].lstrip("/")
|
|
47
|
+
return rel or "."
|
|
48
|
+
return str(path)
|
|
49
|
+
else:
|
|
50
|
+
return str(path.relative_to(root))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_file_tree(
|
|
54
|
+
root: AnyPath,
|
|
55
|
+
workspace_root: AnyRoot,
|
|
56
|
+
lazy_load: bool = True
|
|
57
|
+
) -> List[Dict]:
|
|
23
58
|
"""
|
|
24
59
|
Build file tree structure.
|
|
25
60
|
|
|
26
61
|
Args:
|
|
27
|
-
root: Directory to scan
|
|
28
|
-
workspace_root: Root workspace directory for relative paths
|
|
62
|
+
root: Directory to scan (Path or VirtualPath)
|
|
63
|
+
workspace_root: Root workspace directory for relative paths (Path or VirtualFilesystem)
|
|
29
64
|
lazy_load: If True, only load immediate children (subdirs not expanded)
|
|
30
65
|
|
|
31
66
|
Returns:
|
|
32
67
|
List of file/folder items
|
|
33
68
|
"""
|
|
34
69
|
items = []
|
|
70
|
+
|
|
71
|
+
# Get the root path object if workspace_root is a VirtualFilesystem
|
|
72
|
+
if isinstance(workspace_root, VirtualFilesystem):
|
|
73
|
+
workspace_root_path = workspace_root.root
|
|
74
|
+
else:
|
|
75
|
+
workspace_root_path = workspace_root
|
|
76
|
+
|
|
35
77
|
try:
|
|
36
|
-
|
|
78
|
+
# Get entries from directory
|
|
79
|
+
if isinstance(root, VirtualPath):
|
|
80
|
+
entries = list(root.iterdir())
|
|
81
|
+
else:
|
|
82
|
+
entries = list(root.iterdir())
|
|
83
|
+
|
|
84
|
+
# Sort: directories first, then by name
|
|
85
|
+
entries = sorted(entries, key=lambda x: (not x.is_dir(), x.name.lower()))
|
|
86
|
+
|
|
37
87
|
for entry in entries:
|
|
38
88
|
if entry.name.startswith('.'):
|
|
39
89
|
continue
|
|
40
|
-
|
|
90
|
+
|
|
91
|
+
rel_path = _relative_path(entry, workspace_root_path)
|
|
92
|
+
|
|
41
93
|
if entry.is_dir():
|
|
42
94
|
# Count immediate children to show if folder is empty
|
|
43
95
|
try:
|
|
@@ -60,23 +112,27 @@ def build_file_tree(root: Path, workspace_root: Path, lazy_load: bool = True) ->
|
|
|
60
112
|
"path": rel_path,
|
|
61
113
|
"viewable": is_text_file(entry.name)
|
|
62
114
|
})
|
|
63
|
-
except PermissionError:
|
|
115
|
+
except (PermissionError, FileNotFoundError):
|
|
64
116
|
pass
|
|
117
|
+
|
|
65
118
|
return items
|
|
66
119
|
|
|
67
120
|
|
|
68
|
-
def load_folder_contents(
|
|
121
|
+
def load_folder_contents(
|
|
122
|
+
folder_path: str,
|
|
123
|
+
workspace_root: AnyRoot
|
|
124
|
+
) -> List[Dict]:
|
|
69
125
|
"""
|
|
70
126
|
Load contents of a specific folder (for lazy loading).
|
|
71
127
|
|
|
72
128
|
Args:
|
|
73
129
|
folder_path: Relative path to the folder from workspace root
|
|
74
|
-
workspace_root: Root workspace directory
|
|
130
|
+
workspace_root: Root workspace directory (Path or VirtualFilesystem)
|
|
75
131
|
|
|
76
132
|
Returns:
|
|
77
133
|
List of file/folder items in the specified folder
|
|
78
134
|
"""
|
|
79
|
-
full_path = workspace_root
|
|
135
|
+
full_path = _get_path(workspace_root, folder_path)
|
|
80
136
|
return build_file_tree(full_path, workspace_root, lazy_load=True)
|
|
81
137
|
|
|
82
138
|
|
|
@@ -193,9 +249,13 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
|
|
|
193
249
|
return components
|
|
194
250
|
|
|
195
251
|
|
|
196
|
-
def read_file_content(
|
|
252
|
+
def read_file_content(
|
|
253
|
+
workspace_root: AnyRoot,
|
|
254
|
+
path: str
|
|
255
|
+
) -> Tuple[Optional[str], bool, Optional[str]]:
|
|
197
256
|
"""Read file content. Returns (content, is_text, error)."""
|
|
198
|
-
full_path = workspace_root
|
|
257
|
+
full_path = _get_path(workspace_root, path)
|
|
258
|
+
|
|
199
259
|
if not full_path.exists() or not full_path.is_file():
|
|
200
260
|
return None, False, "File not found"
|
|
201
261
|
|
|
@@ -211,9 +271,13 @@ def read_file_content(workspace_root: Path, path: str) -> Tuple[str, bool, str]:
|
|
|
211
271
|
return None, False, "Binary file - download to view"
|
|
212
272
|
|
|
213
273
|
|
|
214
|
-
def get_file_download_data(
|
|
274
|
+
def get_file_download_data(
|
|
275
|
+
workspace_root: AnyRoot,
|
|
276
|
+
path: str
|
|
277
|
+
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
215
278
|
"""Get file data for download. Returns (base64_content, filename, mime_type)."""
|
|
216
|
-
full_path = workspace_root
|
|
279
|
+
full_path = _get_path(workspace_root, path)
|
|
280
|
+
|
|
217
281
|
if not full_path.exists():
|
|
218
282
|
return None, None, None
|
|
219
283
|
|
|
@@ -222,7 +286,7 @@ def get_file_download_data(workspace_root: Path, path: str) -> Tuple[str, str, s
|
|
|
222
286
|
b64 = base64.b64encode(content).decode('utf-8')
|
|
223
287
|
|
|
224
288
|
# Determine MIME type
|
|
225
|
-
ext =
|
|
289
|
+
ext = PurePosixPath(path).suffix.lower()
|
|
226
290
|
mime_types = {
|
|
227
291
|
".txt": "text/plain", ".py": "text/x-python", ".js": "text/javascript",
|
|
228
292
|
".json": "application/json", ".html": "text/html", ".css": "text/css",
|
|
@@ -232,6 +296,71 @@ def get_file_download_data(workspace_root: Path, path: str) -> Tuple[str, str, s
|
|
|
232
296
|
}
|
|
233
297
|
mime = mime_types.get(ext, "application/octet-stream")
|
|
234
298
|
|
|
235
|
-
|
|
299
|
+
# Get filename
|
|
300
|
+
if isinstance(full_path, VirtualPath):
|
|
301
|
+
filename = full_path.name
|
|
302
|
+
else:
|
|
303
|
+
filename = full_path.name
|
|
304
|
+
|
|
305
|
+
return b64, filename, mime
|
|
236
306
|
except Exception:
|
|
237
307
|
return None, None, None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def write_file(
|
|
311
|
+
workspace_root: AnyRoot,
|
|
312
|
+
path: str,
|
|
313
|
+
content: Union[str, bytes],
|
|
314
|
+
encoding: str = "utf-8"
|
|
315
|
+
) -> bool:
|
|
316
|
+
"""
|
|
317
|
+
Write content to a file.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
workspace_root: Root workspace directory (Path or VirtualFilesystem)
|
|
321
|
+
path: Relative path to the file
|
|
322
|
+
content: Content to write (str or bytes)
|
|
323
|
+
encoding: Encoding for text content
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
True if successful, False otherwise
|
|
327
|
+
"""
|
|
328
|
+
full_path = _get_path(workspace_root, path)
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
if isinstance(content, str):
|
|
332
|
+
full_path.write_text(content, encoding=encoding)
|
|
333
|
+
else:
|
|
334
|
+
full_path.write_bytes(content)
|
|
335
|
+
return True
|
|
336
|
+
except Exception as e:
|
|
337
|
+
print(f"Error writing file {path}: {e}")
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def create_directory(
|
|
342
|
+
workspace_root: AnyRoot,
|
|
343
|
+
path: str,
|
|
344
|
+
parents: bool = True,
|
|
345
|
+
exist_ok: bool = True
|
|
346
|
+
) -> bool:
|
|
347
|
+
"""
|
|
348
|
+
Create a directory.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
workspace_root: Root workspace directory (Path or VirtualFilesystem)
|
|
352
|
+
path: Relative path to the directory
|
|
353
|
+
parents: Create parent directories if needed
|
|
354
|
+
exist_ok: Don't error if directory exists
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
True if successful, False otherwise
|
|
358
|
+
"""
|
|
359
|
+
full_path = _get_path(workspace_root, path)
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
full_path.mkdir(parents=parents, exist_ok=exist_ok)
|
|
363
|
+
return True
|
|
364
|
+
except Exception as e:
|
|
365
|
+
print(f"Error creating directory {path}: {e}")
|
|
366
|
+
return False
|
cowork_dash/layout.py
CHANGED
|
@@ -13,7 +13,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
13
13
|
Create the app layout with current configuration.
|
|
14
14
|
|
|
15
15
|
Args:
|
|
16
|
-
workspace_root: Path to workspace directory
|
|
16
|
+
workspace_root: Path to workspace directory (or None for virtual FS mode)
|
|
17
17
|
app_title: Application title
|
|
18
18
|
app_subtitle: Application subtitle
|
|
19
19
|
colors: Color scheme dictionary
|
|
@@ -27,6 +27,12 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
27
27
|
# Use provided welcome message or fall back to default
|
|
28
28
|
message = welcome_message if welcome_message is not None else DEFAULT_WELCOME_MESSAGE
|
|
29
29
|
|
|
30
|
+
# Build initial file tree (empty if workspace_root is None for virtual FS mode)
|
|
31
|
+
if workspace_root is not None:
|
|
32
|
+
initial_file_tree = render_file_tree(build_file_tree(workspace_root, workspace_root), colors, styles)
|
|
33
|
+
else:
|
|
34
|
+
initial_file_tree = [] # Empty tree for virtual FS - will be populated per-session
|
|
35
|
+
|
|
30
36
|
return dmc.MantineProvider(
|
|
31
37
|
id="mantine-provider",
|
|
32
38
|
forceColorScheme="light",
|
|
@@ -39,6 +45,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
39
45
|
dcc.Store(id="pending-message", data=None),
|
|
40
46
|
dcc.Store(id="skip-history-render", data=False), # Flag to skip display_initial_messages render
|
|
41
47
|
dcc.Store(id="session-initialized", data=False), # Flag to track if session has been initialized
|
|
48
|
+
dcc.Store(id="session-id", data=None, storage_type="session"), # Session ID for virtual FS isolation
|
|
42
49
|
dcc.Store(id="expanded-folders", data=[]),
|
|
43
50
|
dcc.Store(id="file-to-view", data=None),
|
|
44
51
|
dcc.Store(id="file-click-tracker", data={}),
|
|
@@ -292,7 +299,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
292
299
|
}),
|
|
293
300
|
html.Div(
|
|
294
301
|
id="file-tree",
|
|
295
|
-
children=
|
|
302
|
+
children=initial_file_tree,
|
|
296
303
|
style={
|
|
297
304
|
"flex": "1",
|
|
298
305
|
"overflowY": "auto",
|