feat: sidebar Repository identique au Workspace-local, pages forge unifiees, GC miroirs vides
FlowDeck CI / lint (push) Successful in 1m37s
FlowDeck CI / test (push) Successful in 25m11s
FlowDeck CI / docker (push) Successful in 1m32s

This commit is contained in:
2026-10-09 22:33:34 -04:00
parent 9ea0cddf3c
commit d95178156f
13 changed files with 1110 additions and 175 deletions
+137 -30
View File
@@ -134,7 +134,8 @@ def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
return []
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True,
gitea_owner: str = "", gitea_repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
@@ -174,37 +175,45 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
active_ws_name = "Workspace"
workspace_pages = []
gitea_workspace = False
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
local_ws_id = 0
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: set owner/repo for client-side tree loading
# AND open the local workspace mirror of the same name in the
# sidebar's top "My Workspaces" section, in parallel with the
# Gitea repository tree.
# Explicit project context (e.g. /gitea-workspace?owner=…&repo=…) wins over
# the cookie: on a direct navigation / bookmark the cookie may be missing,
# yet the project is unambiguously identified by the URL. Without this the
# sidebar showed "No workspace open" even though the route just created the
# local mirror.
eff_owner, eff_repo = gitea_owner, gitea_repo
if not (eff_owner and eff_repo) and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# Load the local mirror workspace tree so it appears in "My
# Workspaces" alongside the Gitea repository section.
if user:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except (ValueError, Exception):
pass
eff_owner, eff_repo = parts[1], parts[2]
if eff_owner and eff_repo:
# Forge workspace (Gitea or GitHub): set owner/repo for client-side
# tree loading AND open the local workspace mirror of the same name
# in the sidebar's top Workspace section, in parallel with the
# Repository tree (same navigation structure as workspace-local).
gitea_owner = eff_owner
gitea_repo = eff_repo
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# Load the local mirror workspace tree so it appears in Workspace
# alongside the Repository section (gitea_repo or github_repo marker).
if user:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
(user["id"], f"{gitea_owner}/{gitea_repo}"),
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except (ValueError, Exception):
pass
elif include_workspace and ws_cookie and user:
try:
wsi = int(ws_cookie)
@@ -248,15 +257,16 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror (le miroir Gitea est un
# Get local workspace ID for forge workspace mirror (le miroir est un
# espace DISTINCT : on ne doit pas écraser ``local_ws_id`` (espace actif).
gitea_mirror_ws_id = 0
if gitea_workspace and gitea_owner and gitea_repo:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
(user["id"], f"{gitea_owner}/{gitea_repo}"),
).fetchone()
if row:
gitea_mirror_ws_id = row["id"]
@@ -614,6 +624,103 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
def _ensure_forge_mirror(user_id: int, owner: str, repo: str, forge: str = "gitea") -> int | None:
"""Ensure the local workspace mirror ``owner/repo`` exists, return its id.
Shared by /gitea-workspace and /github-workspace so selecting a repository
always opens a workspace-local of the same name (section Workspace).
Only auto-created mirrors carry ``settings_json`` with ``gitea_repo`` /
``github_repo`` — never touch regular user workspaces.
"""
import json as _json
ws_key = f"{owner}/{repo}" if owner and repo else ""
if not ws_key or not user_id:
return None
marker = f"{forge}_repo"
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user_id, ws_key, f"%{marker}%"),
).fetchone()
if existing:
return existing["id"]
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
(ws_key, user_id, _json.dumps({marker: ws_key, f"{forge}_owner": owner})),
)
mirror_id = cur.lastrowid
conn.execute(
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
(mirror_id, user_id, "admin"),
)
conn.commit()
return mirror_id
def _is_mirror_empty(conn, ws_id: int) -> bool:
"""True when no file/folder was ever created in this mirror workspace.
« Vide » = zéro page active (``deleted_at IS NULL``). Trash / soft-deleted
rows don't count — an emptied-then-trashed mirror is still collectable.
"""
row = conn.execute(
"SELECT COUNT(*) AS c FROM pages WHERE workspace_id=? AND deleted_at IS NULL",
(ws_id,),
).fetchone()
return (row["c"] if row else 0) == 0
def _cleanup_empty_mirrors(user_id: int, keep_name: str = "", keep_id: int = 0) -> int:
"""Delete empty auto-created forge mirrors, except the one being opened.
Called when switching workspace / repository so empty workspace-locals
don't accumulate. Only workspaces whose ``settings_json`` contains
``gitea_repo`` or ``github_repo`` are eligible — regular workspaces are
never deleted here. Returns number of removed mirrors.
"""
if not user_id:
return 0
removed = 0
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id=? "
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
(user_id,),
).fetchall()
for r in rows:
wid, name = r["id"], r["name"]
if keep_id and wid == keep_id:
continue
if keep_name and name == keep_name:
continue
try:
if not _is_mirror_empty(conn, wid):
continue
except Exception:
continue
try:
from app.services.collection_lifecycle import (
delete_collections,
workspace_collection_ids,
)
try:
collections = workspace_collection_ids(conn, wid)
if collections:
delete_collections(conn, collections)
except Exception:
logger.exception("_cleanup_empty_mirrors collections")
conn.execute("DELETE FROM pages WHERE workspace_id=?", (wid,))
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (wid,))
conn.execute("DELETE FROM workspaces WHERE id=?", (wid,))
removed += 1
except Exception:
logger.exception("_cleanup_empty_mirrors")
continue
conn.commit()
return removed
def _sanitize_id(block_id: str) -> str:
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
if not block_id: