This file documents the project detail page, the per-project virtual filesystem, and the visibility/read-only UI-level behavior for code under routers/projects/. Claude Code auto-loads it whenever a file in this directory is read or edited.

Routing overview

  • /projects - projects/ package: index.py (listing/detail/create/delete plus owner-only visibility toggles POST /{slug}/private and POST /{slug}/readonly, and async POST /{slug}/fork), files.py, and the containers/ subpackage. main.py mounts the whole /projects tree from this one package.
  • /projects/{slug}/files - projects/files.py, the per-project virtual filesystem (CRUD dirs/files, upload, inline edit, plus line-range ops: lines read, replace-lines, insert-lines, delete-lines, append for large text files).

Project Detail Page

Each project card links to /projects/{project_uid} showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see Project visibility and read-only below). The route is GET /projects/{project_uid} in routers/projects/index.py and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old ?user_uid= query param). The detail page also links to the project filesystem at /projects/{slug}/files.

Project overview + devlog (SEO surface). The detail page is a dedicated project showcase (.project-page, full --max-content measure): a hero card (.project-hero) whose cover banner is the project's first image attachment (brand-gradient fallback band when there are none), the h1 title + status chip, type badge + platform chips + Private/Read-only badges, dates/forked-from meta, the author row with an owner-set Visit Website CTA (projects.website_url, optional, validated/normalized by models.normalize_website_url - scheme-less input gets https://, non-http(s) rejected; edited in the create and edit modals, rel="noopener nofollow" on render), and the action row (unchanged wiring incl. the .project-actions-more overflow). Below the hero an anchor section tab bar (.project-tabs, sticky) links Overview #about / Devlog #devlog / Screenshots #screenshots (only when images exist) / Comments #comments / the Files page - server-rendered anchors, no JS tab state, so crawlers see the whole page. The body is a two-column grid (.project-columns, sidebar collapses at 1024px): the main column holds About (description + non-image attachments), the Devlog (h2, every post whose project_uid points at the project via _post_card.html - the template loads feed.css for the card styles alongside post.css, same rule as news.html), a Screenshots gallery (all image attachments, data-lightbox thumbnails), and Comments; the sidebar holds Links (website / files / fork source), the Stats card (.project-stats, 5 .project-stat entries + last-update line), and the Author card. devlog_count (content.count_project_devlog) and comment_count ride the context and ProjectDetailOut; website_url rides ProjectOut. The owner gets a Post update button (.project-devlog-post-btn) opening the shared create-post composer preset to topic=devlog + this project - the composer form lives ONCE in templates/_post_composer_form.html (locals _composer_topic, _composer_project) and is included by both feed.html and project_detail.html; never fork a second copy of that form. Locator discipline: the page has several Files anchors (action row, tab bar, sidebar) and, for owners, a second hidden textarea[name='content']/Post button inside the composer modal - tests MUST scope (.project-detail-actions a:has-text('Files'), .comment-form textarea[name='content']).

JSON-LD (seo.py): software_application_schema(project, base, image_url=, star_count=, comment_count=, screenshot_urls=) (screenshots = up to 6 absolute image-attachment urls -> screenshot; website_url -> sameAs) is type-aware via project_schema_type (game -> VideoGame with gamePlatform, website -> WebApplication, software/mobile_app -> SoftwareApplication, game_asset -> CreativeWork), adds keywords (type + platforms), image, an aggregateRating when the project has stars, and a comment InteractionCounter. project_devlog_schema(project, devlog_posts, base) emits a Blog node (@id = {project_url}#devlog) with one BlogPosting per rendered devlog entry; it returns None for an empty devlog (dropped by combine). The devlog cursor rides base_seo_context(next_url=next_page_url(request, devlog_next_cursor)) for a crawlable rel=next, keywords carries title/type/platforms, and the sitemap's project lastmod is max(created_at, updated_at, newest devlog post) via one grouped posts query in _build_sitemap. The before devlog cursor is documented on projects-detail in docs_api/groups/content.py.

Action row overflow. The detail page has more actions than fit one line, so project_detail.html keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single More button (.project-actions-more) that opens the shared app.contextMenu. The secondary actions (Workspace, Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden .project-actions-overflow container, each tagged data-menu-action plus data-menu-icon/data-menu-label. static/js/ProjectActionsMenu.js builds the menu from those elements and each item's onSelect simply .click()s the real element, so all existing wiring is reused unchanged - app.zipDownloader (data-zip-download), app.projectForker (data-fork-project), data-share, the data-modal Edit trigger, and the delegated data-confirm/data-confirm-danger dialog on the owner forms. The open handler must stopPropagation() because app.contextMenu's document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click attach, not a left-click). Reuse this pattern - a More trigger over [data-menu-action] real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks.

Owner editing. Mirrors post editing exactly: an owner-only Edit menu item (data-modal="edit-project-modal") opens the modal() macro's edit-project-modal, a plain POST form to /projects/edit/{slug} (route edit_project in routers/projects/index.py, body ProjectEditForm, owner-gated through the shared content.edit_content_item which returns 403 JSON / redirect for non-owners and stamps updated_at). The modal is the create modal pre-filled from the project row (title, description, type/status radios pre-checked, dates via format_date() back to DD/MM/YYYY). is_private/read_only are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's platforms-input/platforms/platforms-tags ids; ProfileEditor.initPlatformTags now seeds existing tags from the hidden #platforms value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool edit_project; documented in docs_api.py (projects-edit).

Project Filesystem

Each project carries a virtual filesystem so it can hold a whole software project. Logic lives in devplacepy/project_files.py (mirrors attachments.py); routes in routers/projects/files.py (registered at prefix /projects, after projects.router). It is public read, owner write - reads use get_current_user, mutations use require_user + is_owner(project, user). Two project flags layer on top (see Project visibility and read-only below): a private project's reads are restricted to owner/admin, and a read-only project refuses every mutation at the data layer.

  • Model. One project_files table, each row a node keyed by a normalized POSIX path unique per project: uid, project_uid, user_uid, path, name, parent_path, type (file/dir), content (text in DB), is_binary, stored_name, directory, mime_type, size, timestamps. Indexes (project_uid, path) and (project_uid, parent_path) in init_db.
  • Text vs binary. Text files store content in the DB (editable inline). Binary uploads write bytes to config.PROJECT_FILES_DIR/<shard>/<stored_name> (= data/uploads/project_files/..., reusing attachments._directory_for/_detect_mime) and are served via the existing /static/uploads mount. The <shard> is the canonical two-level xx/yy tree taken from the random tail of the uuid7 ({tail[-2:]}/{tail[-4:-2]}), never its time-ordered head - see the root CLAUDE.md "Blob sharding" note for why the tail is mandatory. store_upload decodes UTF-8 texty files into editable DB content; everything else is binary.
  • Path is the security control. normalize_path rejects .., null bytes, control chars, and empty/over-long paths; a leading / is normalized to relative. The logical path is never used as a real FS path (blobs are uid-hashed), so traversal is structurally impossible. write/upload/mkdir create parent dirs recursively (ensure_dirs). Caps: MAX_TEXT_CHARS 400000, MAX_FILES_PER_PROJECT 5000.
  • Routes (all POST mutations, JSON via respond/action_result; file path travels in query/body path, not a path param): GET .../files (page or ProjectFilesOut), GET .../files/raw?path=, POST .../files/{write,upload,mkdir,move,delete}. move rewrites a dir's descendants by path prefix; delete is recursive and unlinks blobs.
  • Line-range editing (large text files). GET .../files/lines?path=&start=&end= returns {path, start, end, total_lines, lines, content}; POST .../files/{replace-lines,insert-lines,delete-lines,append} mutate a text file surgically without resending the whole thing. Helpers in project_files.py (read_lines, replace_lines, insert_lines, delete_lines, append_lines) work on a (lines, trailing_newline) model via _split_lines/_join_lines, are 1-indexed inclusive, enforce MAX_TEXT_CHARS, and reject binary/dir/missing paths. These are the preferred way to edit existing files; write replaces the whole file. They never create parents (the file must exist).
  • Cascade. content.py delete_content_item calls delete_all_project_files(uid) when target_type == "project".
  • Frontend. templates/project_files.html (IDE layout: tree + CodeMirror editor / media preview, same CodeMirror vendor as gists), static/js/ProjectFiles.js (tree from the flat list, open/save/new/upload/rename/delete over JSON), static/css/project_files.css. The CodeMirror mode map is shared via static/js/codemirrorModes.js (used by GistEditor.js too).
  • Devii + API. http catalog actions cover the full filesystem: project_list_files/read_file/read_lines/write_file/replace_lines/insert_lines/delete_lines/append_file/upload_file/make_dir/move_file/delete_file, plus the project-files docs_api.py group. Because PlatformClient sends Accept: application/json, the same routes serve the agent and the API.
  • Overwrite-protection guard (agent only). Dispatcher._run_http blocks project_write_file for a (slug, path) only when the file already exists and the agent has not read it this session - it must call project_read_file first. Existence is probed live via _file_exists (GET /projects/{slug}/files/raw, 200 = exists, 404 = new), so creating a brand-new file is never blocked. Reads of project_read_file/project_read_lines (and a successful write) record the path in the per-session Dispatcher._read_files set; the key is normalize_pathd so read and write paths match. This enforces "look before you overwrite" and steers the agent to the surgical line tools for existing files; it does not affect the human UI or the public HTTP API (the guard lives in the Devii dispatcher, not the route). The system prompt's WRITING AND EDITING FILES rule mirrors this.

Project visibility and read-only

Two owner-controlled flags on the projects row, both integer 0/1 (default 0, set on the create insert so the columns always exist): is_private hides the project from other viewers, read_only freezes its filesystem against every mutation. The full security predicates (content.can_view_project, owns_instance, can_view_project_containers, can_view_instance, can_manage_instance, the primary-administrator container isolation rules) are documented once in the root CLAUDE.md's "Project visibility (is_private) and read-only" section - this file covers only the project-page/filesystem UI-level behavior that sits on top of those predicates.

Toggle routes (owner-only, routers/projects/index.py): POST /projects/{slug}/private and POST /projects/{slug}/readonly, each taking ProjectFlagForm{value: bool} and routed through _set_project_flag. The create form carries an is_private checkbox (ProjectForm.is_private); project_detail.html shows Private/Read-only badges and owner toggle buttons (both the private and read-only buttons carry data-confirm so ModalManager.initConfirmations gates them through app.dialog), and project_files.html hides the editing toolbar + shows a banner when read-only. Schemas: is_private/read_only on ProjectOut and ProjectDetailOut, read_only/is_private on ProjectFilesOut.

Devii. Actions project_set_private and project_set_readonly (catalog). Both are gated by an explicit-confirmation requirement: dispatcher.confirmation_error(name, arguments) (driven by the CONFIRM_REQUIRED set, which lists both action names) is checked in Dispatcher.dispatch BEFORE any HTTP call and returns a ToolInputError telling the agent to obtain user confirmation unless confirm is truthy (the message for project_set_private adapts to the value argument: making private vs. making public). Each action declares confirm as a required body param, and the PROJECT PRIVACY AND READ-ONLY system-prompt rule tells the agent to ask first and only then pass confirm=true. This mirrors the overwrite-protection guard pattern above: a Devii-only gate that the human UI and HTTP API do not see.

Deletion confirmation gate

confirmation_error also blocks every Devii deletion until confirm is truthy. Unconditional entries in CONFIRM_REQUIRED: every content delete - delete_post, delete_comment, delete_gist, delete_project, project_delete_file, delete_media, delete_attachment, admin_delete_news - plus project_set_readonly and the customization mutations; a generic fallback message covers any name in the set that lacks a bespoke message. Load-bearing invariant: every gated tool MUST declare a confirm body param (the confirm() helper in catalog.py, or arg("confirm", ..., kind="boolean") for container actions). Tool schemas are additionalProperties: false, so the model can only send arguments that are declared properties - a gated tool with NO confirm param can never be confirmed, so confirmation_error refuses every call and the agent loops forever asking for a confirmation it has no way to express. This was a real production issue: an admin asked Devii to delete a user's 27 posts, confirmed repeatedly, and all 27 delete_post calls were rejected because delete_post (and delete_project/delete_media/project_delete_file/container_*) never exposed confirm. The param is harmlessly forwarded to the HTTP endpoint (routes do not declare it, FastAPI drops undeclared form fields) and ignored by the LOCAL container/customization controllers. Conditional entries (gated by inspecting arguments, not membership): container_instance_action only when action="delete" (start/stop/restart/pause/resume/sync are unaffected), and container_exec only when its command matches dispatcher.DESTRUCTIVE_COMMAND - a token-aware regex for rm/rmdir/unlink/shred/truncate/dd/mkfs*/wipefs (with or without a leading sudo), find ... -delete, redirection over a file (> /...), and SQL drop table|database / delete from. Benign commands (pip install, ls, grep -rm) are not matched. The error message echoes the exact path/command so the agent shows the user what will be removed; after a clear yes it retries with confirm=true. The DELETING IS ALWAYS CONFIRMED system-prompt section in agent.py mirrors the rule. This exists because an unconfirmed container_exec rm -f once deleted a live project database mid-"test the site"; the gate is the data-loss backstop. The regex is a heuristic (it cannot catch python -c "os.remove(...)" and similar), so it is defense-in-depth, not a sandbox - the system-prompt rule is the primary control.

Async fork

POST /projects/{slug}/fork enqueues an async job that copies the whole virtual filesystem into a new project owned by the forking user. See devplacepy/services/jobs/CLAUDE.md for ForkService internals.