feat: sidebar Repository identique au Workspace-local, pages forge unifiees, GC miroirs vides
This commit is contained in:
@@ -456,10 +456,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||
# Gitea workspace: preserve context across pages. Also load the local
|
||||
# mirror workspace so it appears in "My Workspaces" in the top section
|
||||
# of the sidebar, in parallel with the Gitea repository tree.
|
||||
if ws_cookie and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
|
||||
# Forge workspace (Gitea/GitHub): preserve context across pages. Also
|
||||
# load the local mirror workspace so it appears in Workspace in
|
||||
# parallel with the Repository tree (same navigation as workspace-local).
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
@@ -472,8 +472,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
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:
|
||||
local_ws_id = row["id"]
|
||||
|
||||
@@ -34,8 +34,19 @@ router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from app.auth.session import SessionManager
|
||||
from app.routers.dashboard._common import _cleanup_empty_mirrors, _ensure_forge_mirror
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# Selecting a repository also opens a workspace-local of the same name:
|
||||
# ensure the mirror exists, prune other empty mirrors.
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user and owner and repo:
|
||||
try:
|
||||
mid = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea")
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=f"{owner}/{repo}", keep_id=mid or 0)
|
||||
except Exception:
|
||||
logger.exception("board mirror ensure")
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -395,9 +395,33 @@ def sidebar_workspace_tree(request: Request):
|
||||
if not ws_cookie:
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
|
||||
|
||||
# Gitea workspace — no server-side tree, loaded client-side
|
||||
if ws_cookie.startswith("gitea:"):
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
|
||||
# Forge workspace (Gitea/GitHub) — the sidebar Workspace section shows the
|
||||
# LOCAL mirror of the repository (the "parallel local workspace"), not a
|
||||
# remote placeholder. Older behaviour returned "Remote workspace", which
|
||||
# wiped the mirror tree on every CRUD refresh.
|
||||
if ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:"):
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
mirror = None
|
||||
with get_conn() as conn:
|
||||
mirror = 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"{parts[1]}/{parts[2]}"),
|
||||
).fetchone()
|
||||
if mirror:
|
||||
pages = _load_workspace_pages(str(mirror["id"]))
|
||||
if pages:
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
)
|
||||
return HTMLResponse(template.render(pages=pages))
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
|
||||
@@ -18,6 +18,7 @@ from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_cleanup_empty_mirrors,
|
||||
_file_page_disk_path,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
@@ -64,6 +65,12 @@ def local_workspace_page(request: Request, folder: int = None, ws: int = None):
|
||||
).fetchone()
|
||||
if requested is not None:
|
||||
ws_row = dict(requested)
|
||||
# Switching workspace: drop other EMPTY forge mirrors (no file or
|
||||
# folder ever created there) instead of keeping empty locals.
|
||||
try:
|
||||
_cleanup_empty_mirrors(user["id"], keep_id=ws_row["id"])
|
||||
except Exception:
|
||||
pass
|
||||
# L'URL fait foi : on aligne le cookie pour que le reste de
|
||||
# l'application (sidebar, API tree, breadcrumbs) suive le même
|
||||
# workspace que celui affiché.
|
||||
|
||||
@@ -13,7 +13,13 @@ from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||
|
||||
from ._common import _get_active_workspace, _get_user_id, _sidebar_data
|
||||
from ._common import (
|
||||
_cleanup_empty_mirrors,
|
||||
_ensure_forge_mirror,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
@@ -125,12 +131,9 @@ def workspace_page(request: Request):
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
"""Gitea workspace — browse repo files (same layout as /local-workspace)."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
@@ -138,27 +141,16 @@ def gitea_workspace_page(request: Request):
|
||||
repo = request.query_params.get("repo", "")
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
ws_name = ws_key or "Gitea Workspace"
|
||||
# Auto-create local workspace mirror for storing local files
|
||||
local_ws_id = None
|
||||
# Ensure the local workspace mirror of the same name BEFORE building the
|
||||
# sidebar, then drop other EMPTY mirrors so switching repositories never
|
||||
# accumulates empty workspace-locals.
|
||||
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea") if ws_key else None
|
||||
if ws_key:
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], ws_key, "%gitea_repo%")
|
||||
).fetchone()
|
||||
if existing:
|
||||
local_ws_id = existing["id"]
|
||||
else:
|
||||
c = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
|
||||
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
|
||||
)
|
||||
local_ws_id = c.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(local_ws_id, user["id"], "admin")
|
||||
)
|
||||
conn.commit()
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
|
||||
# Build the sidebar with the explicit project context so the local mirror
|
||||
# (''Workspace'' section) is resolved even when the flowdeck_workspace
|
||||
# cookie is absent (direct navigation / bookmark).
|
||||
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
@@ -167,6 +159,7 @@ def gitea_workspace_page(request: Request):
|
||||
"gitea_workspace": True, # always true on this page
|
||||
"gitea_owner": owner,
|
||||
"gitea_repo": repo,
|
||||
"forge": "gitea",
|
||||
"workspace_name": ws_name,
|
||||
"workspace_initial": repo[0].upper() if repo else "G",
|
||||
"owner": owner,
|
||||
@@ -179,6 +172,45 @@ def gitea_workspace_page(request: Request):
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/github-workspace", response_class=HTMLResponse)
|
||||
def github_workspace_page(request: Request):
|
||||
"""GitHub workspace — same layout as /local-workspace and /gitea-workspace."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
owner = request.query_params.get("owner", "")
|
||||
repo = request.query_params.get("repo", "")
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
ws_name = ws_key or "GitHub Workspace"
|
||||
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="github") if ws_key else None
|
||||
if ws_key:
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
|
||||
# Reuse the same sidebar mechanism (Repository section + parallel local
|
||||
# mirror). The cookie prefix carries the forge so refresh APIs resolve it.
|
||||
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"active_ws_name": ws_name,
|
||||
"workspace_key": ws_key,
|
||||
"gitea_workspace": True, # sidebar Repository section visible
|
||||
"gitea_owner": owner,
|
||||
"gitea_repo": repo,
|
||||
"forge": "github",
|
||||
"workspace_name": ws_name,
|
||||
"workspace_initial": repo[0].upper() if repo else "G",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"nav_workspace_id": local_ws_id or 0,
|
||||
}
|
||||
template = env.get_template("github_workspace.html")
|
||||
resp = HTMLResponse(content=template.render(**ctx))
|
||||
resp.set_cookie("flowdeck_workspace", f"github:{owner}:{repo}", path="/", samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/workspace/projects")
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.services.collection_lifecycle import (
|
||||
|
||||
from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_cleanup_empty_mirrors,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_require_user_id,
|
||||
@@ -158,7 +159,12 @@ def delete_workspace(request: Request, ws_id: int):
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
_require_ws_owner(request, ws_id)
|
||||
uid = _require_ws_owner(request, ws_id)
|
||||
# Switching workspace: prune other empty forge mirrors.
|
||||
try:
|
||||
_cleanup_empty_mirrors(uid, keep_id=ws_id)
|
||||
except Exception:
|
||||
pass
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
"""GitHub OAuth — status and disconnect routes."""
|
||||
from fastapi import APIRouter, Request
|
||||
"""GitHub OAuth + repository browsing (mirrors /api/gitea shape for shared UI)."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["github"], prefix="/api/github")
|
||||
|
||||
|
||||
def _require_github(request: Request):
|
||||
"""Return a per-user GitHubAdapter or raise 401 (same contract as Gitea)."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' "
|
||||
"AND access_token IS NOT NULL AND access_token != '' ORDER BY updated_at DESC LIMIT 1",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="GitHub account not linked. Go to Settings → Integrations to connect.")
|
||||
return GitHubAdapter(row["access_token"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def github_status(request: Request):
|
||||
"""Check if the current user has GitHub linked."""
|
||||
@@ -33,3 +53,68 @@ def disconnect_github(request: Request):
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Repo tree (same shape as /api/gitea/projects/{owner}/{repo}/tree) ──
|
||||
@router.get("/projects/{owner}/{repo}/tree")
|
||||
async def github_repo_tree(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""List one level of a GitHub repo (folders first client-side)."""
|
||||
adapter = _require_github(request)
|
||||
try:
|
||||
tree = await adapter.get_repo_tree(owner, repo)
|
||||
prefix = path.strip("/").strip()
|
||||
items = []
|
||||
seen_dirs: set[str] = set()
|
||||
for entry in tree:
|
||||
p = (entry.get("path") or "").strip("/")
|
||||
if not p:
|
||||
continue
|
||||
if prefix:
|
||||
if p != prefix and not p.startswith(prefix + "/"):
|
||||
continue
|
||||
rel = p[len(prefix) + 1:] if p != prefix else ""
|
||||
else:
|
||||
rel = p
|
||||
if not rel:
|
||||
continue
|
||||
if "/" in rel:
|
||||
top = rel.split("/")[0]
|
||||
dir_path = f"{prefix}/{top}".strip("/") if prefix else top
|
||||
if dir_path not in seen_dirs:
|
||||
seen_dirs.add(dir_path)
|
||||
items.append({"name": top, "path": dir_path, "type": "folder", "size": 0, "sha": ""})
|
||||
else:
|
||||
is_dir = entry.get("type") == "tree"
|
||||
items.append({
|
||||
"name": rel,
|
||||
"path": p,
|
||||
"type": "folder" if is_dir else "file",
|
||||
"size": entry.get("size", 0),
|
||||
"sha": entry.get("sha", ""),
|
||||
})
|
||||
# De-duplicate (recursive tree can list both tree + blob entries)
|
||||
dedup: dict[str, dict] = {}
|
||||
for it in items:
|
||||
key = it["path"]
|
||||
if key not in dedup or (dedup[key]["type"] == "file" and it["type"] == "folder"):
|
||||
dedup[key] = it
|
||||
return {"tree": sorted(dedup.values(), key=lambda x: (x["type"] != "folder", x["name"].lower()))}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/file")
|
||||
async def github_get_file(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""Get decoded file content (same shape as Gitea endpoint)."""
|
||||
adapter = _require_github(request)
|
||||
if not path:
|
||||
return JSONResponse({"error": "Path required"}, status_code=400)
|
||||
try:
|
||||
content = await adapter.get_file_content(owner, repo, path)
|
||||
return {"content": content, "path": path}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
+52
-15
@@ -329,16 +329,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
|
||||
<!-- Repository (forge) — same navigation structure as Workspace-local -->
|
||||
{% if gitea_workspace %}
|
||||
<div class="sidebar-section" x-show="isSectionVisible('gitea')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||
<div class="sidebar-section-header" @click="toggleSection('gitea')" @contextmenu.prevent="openSectionMenu($event, 'gitea')">
|
||||
<div class="sidebar-section-title">
|
||||
<span class="chevron" :class="{ open: sectionsOpen.gitea }">▶</span>
|
||||
<span class="section-icon">{{ fd_icon("link",14) }}</span>
|
||||
<span class="section-label">Repository (Gitea)</span>
|
||||
<span class="section-label">Repository{% if forge %} ({{ forge|capitalize }}){% elif gitea_owner and gitea_repo %} (Gitea){% endif %}</span>
|
||||
</div>
|
||||
<div class="sidebar-section-actions">
|
||||
<button class="section-action-btn" title="New file in local mirror" @click.stop="fdGwNewFile()">{{ fd_icon("file",16) }}</button>
|
||||
<button class="section-action-btn" title="New folder in local mirror" @click.stop="fdGwNewFolder()">{{ fd_icon("folder",16) }}</button>
|
||||
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
|
||||
</div>
|
||||
@@ -1441,6 +1443,7 @@
|
||||
giteaWorkspace: {{ 'true' if gitea_workspace else 'false' }},
|
||||
giteaOwner: '{{ gitea_owner|default("") }}',
|
||||
giteaRepo: '{{ gitea_repo|default("") }}',
|
||||
forge: '{{ forge|default("gitea") }}',
|
||||
giteaTree: [], // loaded client-side for sidebar
|
||||
currentView: 'home',
|
||||
|
||||
@@ -2123,7 +2126,16 @@
|
||||
.catch(function(err) { self.toast('Meeting note creation failed: ' + err.message, 'error'); });
|
||||
},
|
||||
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
|
||||
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
|
||||
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); else this.loadGiteaTree(); },
|
||||
forgeTreeBase() {
|
||||
// Gitea and GitHub share the same sidebar tree shape; only the API
|
||||
// prefix differs. Defaults to Gitea for existing cookies/pages.
|
||||
var f = (this.forge || '').toLowerCase();
|
||||
if (f === 'github') return '/api/github/projects/' + this.giteaOwner + '/' + this.giteaRepo;
|
||||
return '/api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo;
|
||||
},
|
||||
fdGwNewFile() { if (window._gwData && window._gwData.showNewFile !== undefined) { window._gwData.showNewFile = true; } else this.newPageInWorkspace(); },
|
||||
fdGwNewFolder() { if (window._gwData && window._gwData.createFolderAtRoot) window._gwData.createFolderAtRoot(); else this.newFolderInWorkspace(); },
|
||||
sidebarSections() {
|
||||
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
|
||||
return {
|
||||
@@ -2350,19 +2362,44 @@
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); });
|
||||
},
|
||||
fdRepoTreeHtml(item, depth) {
|
||||
// Shared Repository row — same DOM contract as _workspace_tree_macro:
|
||||
// .sidebar-item.ws-tree-item + data-is-folder/depth/name + chevron +
|
||||
// .page-icon/.page-name. Depth indent matches local (4 + depth*12px).
|
||||
var isFolder = item.type === 'folder' || item.type === 'dir';
|
||||
var safe = (item.name || '').replace(/'/g, "\\'");
|
||||
var pad = 4 + (depth || 0) * 12;
|
||||
var chev = isFolder
|
||||
? '<button class="tree-chevron" data-repo-toggle="' + item.path + '" title="Toggle folder">▶</button>'
|
||||
: '<span style="width:12px;flex-shrink:0;"></span>';
|
||||
var icon = isFolder ? getSvgIcon('folder', 14) : getSvgIcon('file', 14);
|
||||
var label = isFolder
|
||||
? '<span class="page-name tree-folder-link" title="Open folder">' + item.name + '</span>'
|
||||
: '<span class="page-name">' + item.name + '</span>';
|
||||
return '<li class="sidebar-item ws-tree-item" data-gitea-path="' + item.path + '"'
|
||||
+ ' data-gitea-type="' + (isFolder ? 'folder' : 'file') + '"'
|
||||
+ ' data-gitea-sha="' + (item.sha || '') + '"'
|
||||
+ ' data-is-folder="' + (isFolder ? 'true' : 'false') + '"'
|
||||
+ ' data-depth="' + (depth || 0) + '" data-name="' + safe + '"'
|
||||
+ ' style="padding-left:' + pad + 'px;">'
|
||||
+ chev + '<span class="page-icon">' + icon + '</span>' + label + '</li>';
|
||||
},
|
||||
loadGiteaTree() {
|
||||
if (!this.giteaWorkspace || !this.giteaOwner || !this.giteaRepo) {
|
||||
console.log('loadGiteaTree: skipped (giteaWorkspace=' + this.giteaWorkspace + ', owner=' + this.giteaOwner + ', repo=' + this.giteaRepo + ')');
|
||||
return;
|
||||
}
|
||||
// Skip if giteaWorkspace component already loaded the tree (has data-gitea-path items)
|
||||
var container = document.getElementById('sidebar-workspace-items');
|
||||
// Fallback loader for the Repository section on pages where the
|
||||
// forge-workspace bundle is not mounted. MUST target
|
||||
// #sidebar-gitea-items — never #sidebar-workspace-items (local mirror).
|
||||
var container = document.getElementById('sidebar-gitea-items');
|
||||
if (container && container.querySelector('[data-gitea-path]')) {
|
||||
console.log('loadGiteaTree: skipped (tree already loaded by giteaWorkspace component)');
|
||||
console.log('loadGiteaTree: skipped (tree already loaded by workspace component)');
|
||||
return;
|
||||
}
|
||||
console.log('loadGiteaTree: fetching /api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree');
|
||||
fetch('/api/gitea/projects/' + this.giteaOwner + '/' + this.giteaRepo + '/tree')
|
||||
var base = this.forgeTreeBase();
|
||||
console.log('loadGiteaTree: fetching ' + base + '/tree');
|
||||
fetch(base + '/tree')
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status + ': ' + r.statusText);
|
||||
return r.json();
|
||||
@@ -2370,7 +2407,7 @@
|
||||
.then(function(data) {
|
||||
this.giteaTree = data.tree || [];
|
||||
if (!container) {
|
||||
console.log('loadGiteaTree: #sidebar-workspace-items not found');
|
||||
console.log('loadGiteaTree: #sidebar-gitea-items not found');
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
@@ -2378,13 +2415,13 @@
|
||||
container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">'+getSvgIcon('file',16)+'</span><span class="page-name text-dim">No files yet</span></li>';
|
||||
return;
|
||||
}
|
||||
this.giteaTree.forEach(function(item) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'sidebar-item';
|
||||
var icon = item.type === 'dir' ? '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>' : '<span class="page-icon-svg">'+getSvgIcon('file',14)+'</span>';
|
||||
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
|
||||
container.appendChild(li);
|
||||
var self = this;
|
||||
var html = '';
|
||||
self.giteaTree.forEach(function(item) {
|
||||
var norm = { name: item.name, path: item.path, sha: item.sha, type: (item.type === 'dir' ? 'folder' : item.type) };
|
||||
html += self.fdRepoTreeHtml(norm, 0);
|
||||
});
|
||||
container.innerHTML = html;
|
||||
console.log('loadGiteaTree: rendered ' + this.giteaTree.length + ' items');
|
||||
}.bind(this))
|
||||
.catch(function(err) {
|
||||
|
||||
@@ -4,41 +4,96 @@
|
||||
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||
{% set page_title = workspace_name %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- highlight.js syntax highlighting (~230 file types via FDHighlight helper) -->
|
||||
<!-- Shared forge layout — identical structure to /local-workspace:
|
||||
ws-split > ws-main, breadcrumb-row + Library-style toolbar
|
||||
(View/Sort/Filter/Search, New file), search bar, filter chips,
|
||||
then tree / list / details / cards / content views bound to the
|
||||
forge tree (filteredTreeItems). File view + editor + commit bar
|
||||
preserved below. -->
|
||||
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
|
||||
<script src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
|
||||
<style>
|
||||
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
|
||||
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
||||
.gw-content h3{font-size:16px;margin-bottom:12px;color:var(--text-dim);}
|
||||
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
|
||||
.gw-content pre[class*="language-"]{background:transparent;border:none;padding:0;margin:0;white-space:pre;word-break:normal;}
|
||||
.gw-content code[class*="language-"]{font-size:13px;line-height:1.5;tab-size:4;}
|
||||
.gw-content .empty-state{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
||||
.gw-content .empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
|
||||
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
|
||||
.gw-content textarea:focus{border-color:var(--accent);}
|
||||
.ws-split{display:flex;max-width:100%;margin:0 auto;padding:0 16px 0;gap:0;min-height:calc(100vh - var(--topbar-height));}
|
||||
.ws-main{flex:1;min-width:0;overflow:auto;}
|
||||
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px 8px 0 0;margin-bottom:0;flex-wrap:wrap;}
|
||||
.breadcrumb-row .nav-arrow{width:22px;height:22px;padding:0;font-size:13px;}
|
||||
.toolbar-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;margin-bottom:2px;flex-wrap:wrap;}
|
||||
.nav-arrow{width:28px;height:28px;font-size:13px;flex-shrink:0;}
|
||||
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
|
||||
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
|
||||
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
|
||||
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
|
||||
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
|
||||
.unified-breadcrumb a:hover{color:var(--accent);}
|
||||
.ws-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;margin-left:auto;}
|
||||
.ws-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;}
|
||||
.ws-icon-btn:hover,.ws-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.ws-icon-btn svg{width:16px;height:16px;}
|
||||
.ws-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
|
||||
.ws-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
|
||||
.ws-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;}
|
||||
.ws-dropdown .dd-item:hover{background:var(--bg-hover);}
|
||||
.ws-dropdown .dd-item.active{color:var(--accent);}
|
||||
.ws-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
|
||||
.lib-toolbar-wrap{position:relative;}
|
||||
.ws-search-bar{margin-bottom:10px;}
|
||||
.ws-search-bar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
|
||||
.ws-search-bar input:focus{border-color:var(--accent);}
|
||||
.filter-row{display:flex;align-items:center;gap:4px;padding:2px 0;margin-bottom:4px;flex-wrap:wrap;}
|
||||
.filter-chip{padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-secondary);cursor:pointer;}
|
||||
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
|
||||
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||
.filter-clear{font-size:11px;color:var(--text-tertiary);cursor:pointer;padding:2px 6px;border-radius:4px;}
|
||||
.filter-clear:hover{color:var(--danger);background:var(--bg-hover);}
|
||||
.ws-tree{list-style:none;margin:0;padding:0;}
|
||||
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
|
||||
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
|
||||
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.ws-tree-item .name.folder{font-weight:500;}
|
||||
.tree-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;}
|
||||
.tree-chevron:hover{color:var(--text);background:var(--bg-hover);}
|
||||
.tree-chevron.open{transform:rotate(90deg);}
|
||||
.ws-table-compact,.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
.ws-table-compact th,.ws-table-enhanced th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
|
||||
.ws-table-compact td,.ws-table-enhanced td{padding:5px 10px;border-bottom:1px solid var(--border);}
|
||||
.ws-table-compact tr:hover td,.ws-table-enhanced tr:hover td{background:var(--bg-hover);}
|
||||
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
|
||||
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;min-height:100px;justify-content:center;}
|
||||
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;}
|
||||
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
|
||||
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
|
||||
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;}
|
||||
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||
.empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
|
||||
.empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
|
||||
.gw-file-toolbar{display:flex;align-items:center;gap:8px;padding:8px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}
|
||||
.gw-file-toolbar strong{color:var(--text);margin-right:12px;}
|
||||
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
||||
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
|
||||
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
|
||||
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
|
||||
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
|
||||
.gw-commit-bar input:focus{border-color:var(--accent);}
|
||||
|
||||
/* highlight.js theme tweaks for dark UI */
|
||||
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
||||
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;}
|
||||
.ctx-item:hover{background:var(--bg-hover);}
|
||||
.ctx-item.ctx-danger{color:var(--danger);}
|
||||
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
|
||||
.btn-primary{background:var(--accent);color:#fff;}
|
||||
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="gw-main" x-data="giteaWorkspace()">
|
||||
<div class="ws-split" x-data="giteaWorkspace()">
|
||||
<div class="ws-main">
|
||||
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
@@ -49,21 +104,6 @@
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<!-- Skeleton loading -->
|
||||
<div x-show="fileLoading" class="skeleton-file">
|
||||
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:75%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:55%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:90%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:35%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:65%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:45%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:70%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:50%;"></div>
|
||||
</div>
|
||||
<!-- Syntax-highlighted code -->
|
||||
<div class="gw-code-head" x-show="!fileLoading" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<span class="gw-code-lang" x-text="fileLanguageLabel" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
@@ -94,24 +134,80 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- File tree browser (like local-workspace) -->
|
||||
<!-- Browser (same chrome as /local-workspace) -->
|
||||
<template x-if="!showFile && !showEditor && !showPrivate">
|
||||
<div class="gw-content">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="gw-breadcrumb" style="display:flex;align-items:center;gap:4px;margin-bottom:16px;font-size:14px;color:var(--text-secondary);">
|
||||
<a href="#" @click.prevent="navigateToRoot()" style="color:var(--text-secondary);text-decoration:none;" x-text="owner + '/' + repo"></a>
|
||||
<template x-for="(b, i) in folderStack" :key="i">
|
||||
<span style="display:contents;">
|
||||
<span style="color:var(--text-tertiary);">/</span>
|
||||
<a href="#" @click.prevent="navigateToFolder(i)" style="color:var(--text-secondary);text-decoration:none;" x-text="b"></a>
|
||||
</span>
|
||||
</template>
|
||||
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
|
||||
<div>
|
||||
<div class="breadcrumb-row">
|
||||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
|
||||
<div class="unified-breadcrumb">
|
||||
<span class="bc-root" x-text="owner + '/' + repo"></span>
|
||||
<template x-for="(b, i) in folderStack" :key="i">
|
||||
<span style="display:contents;">
|
||||
<span class="bc-sep">/</span>
|
||||
<a href="#" @click.prevent="navigateToFolder(i)" x-text="b"></a>
|
||||
</span>
|
||||
</template>
|
||||
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
|
||||
</div>
|
||||
<div class="ws-toolbar">
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">View</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">Sort by</div>
|
||||
<div class="dd-item" :class="{active: sortBy===''}" @click="sortBy='', doSort(), sortOpen=false"><span class="check" x-text="sortBy===''?'✓':''"></span> Name A→Z</div>
|
||||
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
|
||||
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">File type</div>
|
||||
<div class="dd-item" :class="{active: filterType==='folder'}" @click="filterByType('folder'), filterOpen=false"><span class="check" x-text="filterType==='folder'?'✓':''"></span>{{ fd_icon("folder",14) }} Folders</div>
|
||||
<div class="dd-item" :class="{active: filterType==='page'}" @click="filterByType('page'), filterOpen=false"><span class="check" x-text="filterType==='page'?'✓':''"></span>{{ fd_icon("file",14) }} Files</div>
|
||||
<div class="dd-item" :class="{active: filterType==='image'}" @click="filterByType('image'), filterOpen=false"><span class="check" x-text="filterType==='image'?'✓':''"></span>{{ fd_icon("image",14) }} Images</div>
|
||||
<div class="dd-item" :class="{active: filterType==='code'}" @click="filterByType('code'), filterOpen=false"><span class="check" x-text="filterType==='code'?'✓':''"></span>{{ fd_icon("file",14) }} Code</div>
|
||||
<div class="more-sep" x-show="filterType"></div>
|
||||
<div class="dd-item" x-show="filterType" @click="filterType='', doFilter(), filterOpen=false" style="color:var(--text-dim);">{{ fd_icon("x",14) }} Clear filter</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<button class="ws-icon-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="ws-icon-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button>
|
||||
<button class="ws-icon-btn" @click="createFolderAtRoot()" title="New folder">{{ fd_icon("folder",14) }}+</button>
|
||||
<button class="ws-icon-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button>
|
||||
<input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-search-bar" x-show="searchOpen" x-transition>
|
||||
<input type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter()" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter ×</span>
|
||||
</div>
|
||||
|
||||
<!-- Tree items -->
|
||||
<div class="gw-content" style="padding:12px 4px 24px;">
|
||||
<div x-show="!treeLoading">
|
||||
<template x-if="treeItems.length === 0">
|
||||
<template x-if="(filteredTreeItems || []).length === 0">
|
||||
<div class="empty-state">
|
||||
<h2>{{ fd_icon("folder",16) }} Empty</h2>
|
||||
<p>No files in this folder</p>
|
||||
@@ -119,12 +215,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="ws-tree">
|
||||
<template x-for="item in sortedTreeItems" :key="item.path">
|
||||
<!-- Tree view -->
|
||||
<div class="ws-tree" x-show="viewMode==='tree'">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="item.path">
|
||||
<div class="ws-tree-item"
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||
@contextmenu.prevent="openGwContext($event, item)">
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
@@ -132,6 +228,59 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<table class="ws-table-compact" x-show="viewMode==='list' && (filteredTreeItems || []).length>0">
|
||||
<thead><tr><th>Name</th><th style="width:80px">Type</th><th style="width:80px">Size</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'l'+item.path">
|
||||
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
|
||||
<td x-text="item.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="item.type"></td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)" x-text="formatSize(item.size)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Details view -->
|
||||
<table class="ws-table-enhanced" x-show="viewMode==='details' && (filteredTreeItems || []).length>0">
|
||||
<thead><tr><th>Name</th><th>Path</th><th>Size</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'d'+item.path">
|
||||
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
|
||||
<td x-text="item.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary);font-family:monospace;" x-text="item.path"></td>
|
||||
<td style="font-size:12px;" x-text="formatSize(item.size)"></td>
|
||||
<td style="font-size:12px;" x-text="item.type"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Cards view -->
|
||||
<div class="title-grid" x-show="viewMode==='title' && (filteredTreeItems || []).length>0">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'g'+item.path">
|
||||
<div class="title-card" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
|
||||
<div class="title-card-icon" x-init="bindGwIcon($el, item)"></div>
|
||||
<div class="title-card-name" x-text="item.name"></div>
|
||||
<div class="title-card-meta"><span x-text="formatSize(item.size)"></span></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Content view -->
|
||||
<div class="content-list" x-show="viewMode==='content' && (filteredTreeItems || []).length>0">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'c'+item.path">
|
||||
<div class="content-item" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
|
||||
<div class="content-item-header">
|
||||
<span x-init="bindGwIcon($el, item)"></span>
|
||||
<span x-text="item.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="formatSize(item.size)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div class="gw-context-menu"
|
||||
x-show="gwCtx.visible"
|
||||
@@ -150,6 +299,7 @@
|
||||
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
|
||||
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -193,6 +343,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}{{ workspace_name }}{% endblock %}
|
||||
{% block title_prefix %}{{ workspace_name }}{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set page_title = workspace_name %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Shared forge layout — identical structure to /local-workspace:
|
||||
ws-split > ws-main, breadcrumb-row + Library-style toolbar
|
||||
(View/Sort/Filter/Search, New file), search bar, filter chips,
|
||||
then tree / list / details / cards / content views bound to the
|
||||
forge tree (filteredTreeItems). File view + editor + commit bar
|
||||
preserved below. -->
|
||||
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
|
||||
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
|
||||
<style>
|
||||
.ws-split{display:flex;max-width:100%;margin:0 auto;padding:0 16px 0;gap:0;min-height:calc(100vh - var(--topbar-height));}
|
||||
.ws-main{flex:1;min-width:0;overflow:auto;}
|
||||
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px 8px 0 0;margin-bottom:0;flex-wrap:wrap;}
|
||||
.breadcrumb-row .nav-arrow{width:22px;height:22px;padding:0;font-size:13px;}
|
||||
.toolbar-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;margin-bottom:2px;flex-wrap:wrap;}
|
||||
.nav-arrow{width:28px;height:28px;font-size:13px;flex-shrink:0;}
|
||||
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
|
||||
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
|
||||
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
|
||||
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
|
||||
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
|
||||
.unified-breadcrumb a:hover{color:var(--accent);}
|
||||
.ws-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;margin-left:auto;}
|
||||
.ws-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;}
|
||||
.ws-icon-btn:hover,.ws-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.ws-icon-btn svg{width:16px;height:16px;}
|
||||
.ws-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
|
||||
.ws-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
|
||||
.ws-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;}
|
||||
.ws-dropdown .dd-item:hover{background:var(--bg-hover);}
|
||||
.ws-dropdown .dd-item.active{color:var(--accent);}
|
||||
.ws-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
|
||||
.lib-toolbar-wrap{position:relative;}
|
||||
.ws-search-bar{margin-bottom:10px;}
|
||||
.ws-search-bar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
|
||||
.ws-search-bar input:focus{border-color:var(--accent);}
|
||||
.filter-row{display:flex;align-items:center;gap:4px;padding:2px 0;margin-bottom:4px;flex-wrap:wrap;}
|
||||
.filter-chip{padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-secondary);cursor:pointer;}
|
||||
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
|
||||
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||
.filter-clear{font-size:11px;color:var(--text-tertiary);cursor:pointer;padding:2px 6px;border-radius:4px;}
|
||||
.filter-clear:hover{color:var(--danger);background:var(--bg-hover);}
|
||||
.ws-tree{list-style:none;margin:0;padding:0;}
|
||||
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
|
||||
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
|
||||
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.ws-tree-item .name.folder{font-weight:500;}
|
||||
.tree-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;}
|
||||
.tree-chevron:hover{color:var(--text);background:var(--bg-hover);}
|
||||
.tree-chevron.open{transform:rotate(90deg);}
|
||||
.ws-table-compact,.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
.ws-table-compact th,.ws-table-enhanced th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
|
||||
.ws-table-compact td,.ws-table-enhanced td{padding:5px 10px;border-bottom:1px solid var(--border);}
|
||||
.ws-table-compact tr:hover td,.ws-table-enhanced tr:hover td{background:var(--bg-hover);}
|
||||
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
|
||||
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;min-height:100px;justify-content:center;}
|
||||
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;}
|
||||
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
|
||||
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
|
||||
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;}
|
||||
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||
.empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
|
||||
.empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
|
||||
.gw-file-toolbar{display:flex;align-items:center;gap:8px;padding:8px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}
|
||||
.gw-file-toolbar strong{color:var(--text);margin-right:12px;}
|
||||
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
||||
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
|
||||
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
|
||||
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
|
||||
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
|
||||
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;}
|
||||
.ctx-item:hover{background:var(--bg-hover);}
|
||||
.ctx-item.ctx-danger{color:var(--danger);}
|
||||
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
|
||||
.btn-primary{background:var(--accent);color:#fff;}
|
||||
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="ws-split" x-data="giteaWorkspace()">
|
||||
<div class="ws-main">
|
||||
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
<strong x-text="filePath || ''"></strong>
|
||||
<span x-text="formatSize(fileSize)"></span>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<div class="gw-code-head" x-show="!fileLoading" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<span class="gw-code-lang" x-text="fileLanguageLabel" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
<pre x-show="!fileLoading"><code class="hljs" :class="'language-'+fileLanguage" :data-hljs-lang="fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor view -->
|
||||
<template x-if="showEditor">
|
||||
<div>
|
||||
<div class="gw-file-toolbar">
|
||||
<strong>Editing: <span x-text="filePath || 'new file'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="showEditor=false">Cancel</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<textarea x-model="editContent"></textarea>
|
||||
</div>
|
||||
<div class="gw-commit-bar">
|
||||
<input type="text" x-model="commitMessage" placeholder="Commit message" list="commit-history">
|
||||
<datalist id="commit-history">
|
||||
<template x-for="msg in commitHistory" :key="msg">
|
||||
<option :value="msg">
|
||||
</template>
|
||||
</datalist>
|
||||
<button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Browser (same chrome as /local-workspace) -->
|
||||
<template x-if="!showFile && !showEditor && !showPrivate">
|
||||
<div>
|
||||
<div class="breadcrumb-row">
|
||||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
|
||||
<div class="unified-breadcrumb">
|
||||
<span class="bc-root" x-text="owner + '/' + repo"></span>
|
||||
<template x-for="(b, i) in folderStack" :key="i">
|
||||
<span style="display:contents;">
|
||||
<span class="bc-sep">/</span>
|
||||
<a href="#" @click.prevent="navigateToFolder(i)" x-text="b"></a>
|
||||
</span>
|
||||
</template>
|
||||
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
|
||||
</div>
|
||||
<div class="ws-toolbar">
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">View</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">Sort by</div>
|
||||
<div class="dd-item" :class="{active: sortBy===''}" @click="sortBy='', doSort(), sortOpen=false"><span class="check" x-text="sortBy===''?'✓':''"></span> Name A→Z</div>
|
||||
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
|
||||
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">File type</div>
|
||||
<div class="dd-item" :class="{active: filterType==='folder'}" @click="filterByType('folder'), filterOpen=false"><span class="check" x-text="filterType==='folder'?'✓':''"></span>{{ fd_icon("folder",14) }} Folders</div>
|
||||
<div class="dd-item" :class="{active: filterType==='page'}" @click="filterByType('page'), filterOpen=false"><span class="check" x-text="filterType==='page'?'✓':''"></span>{{ fd_icon("file",14) }} Files</div>
|
||||
<div class="dd-item" :class="{active: filterType==='image'}" @click="filterByType('image'), filterOpen=false"><span class="check" x-text="filterType==='image'?'✓':''"></span>{{ fd_icon("image",14) }} Images</div>
|
||||
<div class="dd-item" :class="{active: filterType==='code'}" @click="filterByType('code'), filterOpen=false"><span class="check" x-text="filterType==='code'?'✓':''"></span>{{ fd_icon("file",14) }} Code</div>
|
||||
<div class="more-sep" x-show="filterType"></div>
|
||||
<div class="dd-item" x-show="filterType" @click="filterType='', doFilter(), filterOpen=false" style="color:var(--text-dim);">{{ fd_icon("x",14) }} Clear filter</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<button class="ws-icon-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="ws-icon-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button>
|
||||
<button class="ws-icon-btn" @click="createFolderAtRoot()" title="New folder">{{ fd_icon("folder",14) }}+</button>
|
||||
<button class="ws-icon-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button>
|
||||
<input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-search-bar" x-show="searchOpen" x-transition>
|
||||
<input type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter()" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter ×</span>
|
||||
</div>
|
||||
|
||||
<div class="gw-content" style="padding:12px 4px 24px;">
|
||||
<div x-show="!treeLoading">
|
||||
<template x-if="(filteredTreeItems || []).length === 0">
|
||||
<div class="empty-state">
|
||||
<h2>{{ fd_icon("folder",16) }} Empty</h2>
|
||||
<p>No files in this folder</p>
|
||||
<button class="btn btn-primary" @click="showNewFile=!showNewFile">{{ fd_icon("file",14) }}+ New file</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tree view -->
|
||||
<div class="ws-tree" x-show="viewMode==='tree'">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="item.path">
|
||||
<div class="ws-tree-item"
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)">
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<table class="ws-table-compact" x-show="viewMode==='list' && (filteredTreeItems || []).length>0">
|
||||
<thead><tr><th>Name</th><th style="width:80px">Type</th><th style="width:80px">Size</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'l'+item.path">
|
||||
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
|
||||
<td x-text="item.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="item.type"></td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)" x-text="formatSize(item.size)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Details view -->
|
||||
<table class="ws-table-enhanced" x-show="viewMode==='details' && (filteredTreeItems || []).length>0">
|
||||
<thead><tr><th>Name</th><th>Path</th><th>Size</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'d'+item.path">
|
||||
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
|
||||
<td x-text="item.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary);font-family:monospace;" x-text="item.path"></td>
|
||||
<td style="font-size:12px;" x-text="formatSize(item.size)"></td>
|
||||
<td style="font-size:12px;" x-text="item.type"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Cards view -->
|
||||
<div class="title-grid" x-show="viewMode==='title' && (filteredTreeItems || []).length>0">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'g'+item.path">
|
||||
<div class="title-card" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
|
||||
<div class="title-card-icon" x-init="bindGwIcon($el, item)"></div>
|
||||
<div class="title-card-name" x-text="item.name"></div>
|
||||
<div class="title-card-meta"><span x-text="formatSize(item.size)"></span></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Content view -->
|
||||
<div class="content-list" x-show="viewMode==='content' && (filteredTreeItems || []).length>0">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'c'+item.path">
|
||||
<div class="content-item" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
|
||||
<div class="content-item-header">
|
||||
<span x-init="bindGwIcon($el, item)"></span>
|
||||
<span x-text="item.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="formatSize(item.size)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div class="gw-context-menu"
|
||||
x-show="gwCtx.visible"
|
||||
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
|
||||
@click.outside="gwCtx.visible = false"
|
||||
style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
|
||||
<div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
|
||||
<div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New file form -->
|
||||
<div x-show="showNewFile" style="margin-top:16px;max-width:400px;">
|
||||
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
|
||||
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
|
||||
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
|
||||
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Private pages view -->
|
||||
<template x-if="showPrivate && !editingPrivate">
|
||||
<div class="gw-content">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3>{{ fd_icon("lock",14) }} Private Pages</h3>
|
||||
<button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button>
|
||||
</div>
|
||||
<template x-if="privatePages.length === 0">
|
||||
<div class="empty-state" style="padding:40px 20px;">
|
||||
<p>No private pages yet. Create one to store notes linked to this project.</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="pp in privatePages" :key="pp.id">
|
||||
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
|
||||
@click="openPrivate(pp.id)">
|
||||
<div>
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Private page editor -->
|
||||
<template x-if="editingPrivate">
|
||||
<div>
|
||||
<div class="gw-file-toolbar">
|
||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
||||
<button class="btn" @click="closePrivateEdit()">Cancel</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
||||
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
+154
-39
@@ -5,8 +5,24 @@ var _regGiteaWs = () => {
|
||||
const owner = params.get('owner') || '';
|
||||
const repo = params.get('repo') || '';
|
||||
|
||||
// Forge is carried by the page route (/gitea-workspace vs /github-workspace);
|
||||
// the sidebar cookie prefix follows it so refresh APIs resolve the mirror.
|
||||
const forge = (window.location.pathname || '').indexOf('/github-workspace') === 0 ? 'github' : 'gitea';
|
||||
|
||||
return {
|
||||
owner, repo,
|
||||
owner, repo, forge,
|
||||
apiBase() {
|
||||
return '/api/' + this.forge + '/projects/' + this.owner + '/' + this.repo;
|
||||
},
|
||||
// ── Library-style toolbar (same as /local-workspace) ──
|
||||
viewMode: localStorage.getItem('fd_default_view') || 'tree',
|
||||
viewMenuOpen: false,
|
||||
sortOpen: false,
|
||||
filterOpen: false,
|
||||
searchOpen: false,
|
||||
sortBy: '',
|
||||
filterType: '',
|
||||
searchQuery: '',
|
||||
showFile: false,
|
||||
showEditor: false,
|
||||
showPrivate: false,
|
||||
@@ -27,6 +43,7 @@ var _regGiteaWs = () => {
|
||||
// File tree browser
|
||||
treeItems: [],
|
||||
sortedTreeItems: [],
|
||||
filteredTreeItems: [],
|
||||
treeLoading: false,
|
||||
folderStack: [],
|
||||
currentPath: '',
|
||||
@@ -34,17 +51,77 @@ var _regGiteaWs = () => {
|
||||
gwCtx: { visible: false, x: 0, y: 0, item: null },
|
||||
|
||||
_sortTree() {
|
||||
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
var items = [...this.treeItems];
|
||||
var by = this.sortBy || '';
|
||||
var typeRank = function(t) { return (t === 'folder' || t === 'dir') ? 0 : 1; };
|
||||
items.sort(function(a, b) {
|
||||
if (by === 'name-desc') return (b.name || '').localeCompare(a.name || '');
|
||||
if (by === 'type') {
|
||||
var d = typeRank(a.type) - typeRank(b.type);
|
||||
return d !== 0 ? d : (a.name || '').localeCompare(b.name || '');
|
||||
}
|
||||
// default name-asc: folders first
|
||||
var td = typeRank(a.type) - typeRank(b.type);
|
||||
if (td !== 0) return td;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
this.sortedTreeItems = items;
|
||||
this.doFilter();
|
||||
},
|
||||
toggleViewMenu() { this.viewMenuOpen = !this.viewMenuOpen; this.sortOpen = false; this.filterOpen = false; },
|
||||
toggleSortMenu() { this.sortOpen = !this.sortOpen; this.viewMenuOpen = false; this.filterOpen = false; },
|
||||
toggleFilterMenu() { this.filterOpen = !this.filterOpen; this.viewMenuOpen = false; this.sortOpen = false; },
|
||||
toggleSearch() {
|
||||
this.searchOpen = !this.searchOpen;
|
||||
if (!this.searchOpen) { this.searchQuery = ''; this.doFilter(); }
|
||||
},
|
||||
doSort() { this._sortTree(); },
|
||||
filterByType(t) { this.filterType = (this.filterType === t ? '' : t); this.doFilter(); },
|
||||
doFilter() {
|
||||
var q = (this.searchQuery || '').toLowerCase();
|
||||
var ft = this.filterType || '';
|
||||
this.filteredTreeItems = (this.sortedTreeItems || []).filter(function(it) {
|
||||
if (ft === 'folder' && it.type !== 'folder') return false;
|
||||
if (ft && ft !== 'folder') {
|
||||
var ext = (it.name || '').split('.').pop().toLowerCase();
|
||||
var kind = (/^(png|jpe?g|gif|svg|webp|ico|bmp)$/.test(ext)) ? 'image'
|
||||
: (/^(pdf)$/.test(ext)) ? 'pdf'
|
||||
: (/^(js|jsx|ts|tsx|py|rb|rs|go|java|kt|c|cpp|h|cs|php|swift|r|lua|dart|sh|sql)$/.test(ext)) ? 'code'
|
||||
: (/^(md|txt|json|ya?ml|toml|ini|csv)$/.test(ext)) ? 'text'
|
||||
: 'page';
|
||||
if (kind !== ft && !(ft === 'page' && it.type === 'file' && kind === 'page')) {
|
||||
// 'page' filter = generic files; keep kind match only
|
||||
if (ft === 'page' && it.type !== 'file') return false;
|
||||
else if (ft !== 'page') return false;
|
||||
}
|
||||
}
|
||||
if (q && (it.name || '').toLowerCase().indexOf(q) < 0 && (it.path || '').toLowerCase().indexOf(q) < 0) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
createFolderAtRoot() {
|
||||
var name = prompt('Folder name:');
|
||||
if (!name || !name.trim()) return;
|
||||
this.createNewFolder(name.trim(), this.currentPath || '');
|
||||
},
|
||||
async createNewFolder(name, basePath) {
|
||||
var path = (basePath ? basePath.replace(/\/$/, '') + '/' : '') + name + '/.gitkeep';
|
||||
try {
|
||||
var r = await fetch(this.apiBase() + '/file', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify({ path: path, content: '', message: 'Create folder ' + name + ' via FlowDeck' })
|
||||
});
|
||||
if (r.ok) await this.refreshTree();
|
||||
else window.showToast('Create folder failed', 'error');
|
||||
} catch (e) { window.showToast('Create folder failed', 'error'); }
|
||||
},
|
||||
|
||||
init() {
|
||||
// Expose globally for header to access
|
||||
window._gwData = this;
|
||||
// Set cookie for sidebar
|
||||
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
|
||||
// Set cookie for sidebar (forge-prefixed so Workspace mirror resolves)
|
||||
document.cookie = 'flowdeck_workspace=' + this.forge + ':' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
|
||||
// Load sidebar tree (into gitea section)
|
||||
this.loadSidebarTree();
|
||||
// Load main content tree
|
||||
@@ -57,14 +134,14 @@ var _regGiteaWs = () => {
|
||||
this.treeLoading = true;
|
||||
this.currentPath = path;
|
||||
try {
|
||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
|
||||
var url = this.apiBase() + '/tree';
|
||||
if (path) url += '?path=' + encodeURIComponent(path);
|
||||
var r = await fetch(url);
|
||||
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
|
||||
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; this.filteredTreeItems = []; return; }
|
||||
var d = await r.json();
|
||||
this.treeItems = d.tree || [];
|
||||
this._sortTree();
|
||||
} catch { this.treeItems = []; this.sortedTreeItems = []; }
|
||||
} catch { this.treeItems = []; this.sortedTreeItems = []; this.filteredTreeItems = []; }
|
||||
finally { this.treeLoading = false; }
|
||||
},
|
||||
|
||||
@@ -97,7 +174,7 @@ var _regGiteaWs = () => {
|
||||
var self = this;
|
||||
var oldPath = item.path;
|
||||
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
fetch(this.apiBase() + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
|
||||
@@ -114,7 +191,7 @@ var _regGiteaWs = () => {
|
||||
if (!item) return;
|
||||
if (!confirm('Delete ' + item.name + '?')) return;
|
||||
var self = this;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': getCsrf()},
|
||||
fetch(this.apiBase() + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': getCsrf()},
|
||||
method: 'DELETE'
|
||||
}).then(function(r){
|
||||
if (r.ok) { self.refreshTree(); }
|
||||
@@ -124,7 +201,7 @@ var _regGiteaWs = () => {
|
||||
|
||||
async loadSidebarTree() {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
|
||||
var r = await fetch(this.apiBase() + '/tree');
|
||||
if (!r.ok) {
|
||||
// If API fails (e.g. no Gitea token), set a message
|
||||
var container = document.getElementById('sidebar-gitea-items');
|
||||
@@ -140,15 +217,31 @@ var _regGiteaWs = () => {
|
||||
window.appState.sectionsOpen.gitea = true;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
// Build tree HTML as string and set once (more performant)
|
||||
// Same DOM contract as Workspace-local (_workspace_tree_macro):
|
||||
// .sidebar-item.ws-tree-item + chevron + .page-icon/.page-name.
|
||||
var html = '';
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||
html += '<span class="page-icon">' + icon + '</span>';
|
||||
html += '<span class="page-name">' + item.name + '</span>';
|
||||
html += '</li>';
|
||||
var sorted = [...items].sort(function(a, b) {
|
||||
var at = (a.type === 'folder' || a.type === 'dir') ? 0 : 1;
|
||||
var bt = (b.type === 'folder' || b.type === 'dir') ? 0 : 1;
|
||||
if (at !== bt) return at - bt;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
var item = sorted[i];
|
||||
var isF = item.type === 'folder' || item.type === 'dir';
|
||||
var safe = (item.name || '').replace(/'/g, "\\'");
|
||||
var chev = isF
|
||||
? '<button class="tree-chevron" data-repo-toggle="' + item.path + '" title="Toggle folder">▶</button>'
|
||||
: '<span style="width:12px;flex-shrink:0;"></span>';
|
||||
var icon = isF ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
var label = isF
|
||||
? '<span class="page-name tree-folder-link" title="Open folder">' + item.name + '</span>'
|
||||
: '<span class="page-name">' + item.name + '</span>';
|
||||
html += '<li class="sidebar-item ws-tree-item" data-gitea-path="' + item.path
|
||||
+ '" data-gitea-type="' + (isF ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '')
|
||||
+ '" data-is-folder="' + (isF ? 'true' : 'false') + '" data-depth="0" data-name="' + safe + '"'
|
||||
+ ' style="padding-left:4px;">' + chev
|
||||
+ '<span class="page-icon">' + icon + '</span>' + label + '</li>';
|
||||
}
|
||||
// Add Private Pages link
|
||||
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
|
||||
@@ -209,7 +302,7 @@ var _regGiteaWs = () => {
|
||||
if (!path) return;
|
||||
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
|
||||
var sha = el.getAttribute('data-gitea-sha') || '';
|
||||
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
|
||||
fetch(self.apiBase() + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
|
||||
method: 'DELETE',
|
||||
}).then(function(r) {
|
||||
if (r.ok) self.refreshTree();
|
||||
@@ -226,21 +319,26 @@ var _regGiteaWs = () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
|
||||
var r = await fetch(this.apiBase() + '/tree?path=' + encodeURIComponent(path));
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
var children = d.tree || [];
|
||||
ul = document.createElement('ul');
|
||||
ul.className = 'sidebar-items';
|
||||
ul.style.paddingLeft = '20px';
|
||||
ul.style.cssText = 'list-style:none;margin:0;padding:0;';
|
||||
var depth = parseInt(el.getAttribute('data-depth') || '0', 10) + 1;
|
||||
children.forEach(function(child) {
|
||||
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
var isF = child.type === 'folder' || child.type === 'dir';
|
||||
var icon = isF ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
var li = document.createElement('li');
|
||||
li.className = 'sidebar-item';
|
||||
li.className = 'sidebar-item ws-tree-item';
|
||||
li.setAttribute('data-gitea-path', child.path);
|
||||
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
|
||||
li.setAttribute('data-gitea-type', isF ? 'folder' : 'file');
|
||||
li.setAttribute('data-gitea-sha', child.sha || '');
|
||||
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
|
||||
li.setAttribute('data-is-folder', isF ? 'true' : 'false');
|
||||
li.setAttribute('data-depth', String(depth));
|
||||
li.setAttribute('data-name', (child.name || '').replace(/'/g, "\\'"));
|
||||
li.style.cssText = 'padding-left:' + (4 + depth * 12) + 'px;';
|
||||
// Checkbox
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
@@ -251,14 +349,29 @@ var _regGiteaWs = () => {
|
||||
self.selectItem(child.path, li, ev);
|
||||
});
|
||||
li.appendChild(cb);
|
||||
// Chevron (folders) — same as workspace-local
|
||||
if (isF) {
|
||||
var chev = document.createElement('button');
|
||||
chev.className = 'tree-chevron';
|
||||
chev.setAttribute('data-repo-toggle', child.path);
|
||||
chev.title = 'Toggle folder';
|
||||
chev.textContent = '▶';
|
||||
li.appendChild(chev);
|
||||
} else {
|
||||
var sp = document.createElement('span');
|
||||
sp.style.cssText = 'width:12px;flex-shrink:0;';
|
||||
li.appendChild(sp);
|
||||
}
|
||||
// Icon
|
||||
var iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'sidebar-icon';
|
||||
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
|
||||
iconSpan.className = 'page-icon';
|
||||
iconSpan.innerHTML = icon;
|
||||
li.appendChild(iconSpan);
|
||||
// Name
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'page-name' + (isF ? ' tree-folder-link' : '');
|
||||
nameSpan.textContent = child.name;
|
||||
if (isF) nameSpan.title = 'Open folder';
|
||||
nameSpan.addEventListener('dblclick', function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -268,6 +381,8 @@ var _regGiteaWs = () => {
|
||||
ul.appendChild(li);
|
||||
});
|
||||
el.appendChild(ul);
|
||||
var chevBtn = el.querySelector('[data-repo-toggle]');
|
||||
if (chevBtn) chevBtn.classList.add('open');
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
|
||||
@@ -279,7 +394,7 @@ var _regGiteaWs = () => {
|
||||
this.fileLoading = true;
|
||||
this.fileLanguage = this.getLanguage(path);
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
|
||||
var r = await fetch(this.apiBase() + '/file?path=' + encodeURIComponent(path));
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.fileContent = d.content || '';
|
||||
@@ -334,7 +449,7 @@ var _regGiteaWs = () => {
|
||||
async saveFile() {
|
||||
if (!this.filePath) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
var r = await fetch(this.apiBase() + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({
|
||||
@@ -361,7 +476,7 @@ var _regGiteaWs = () => {
|
||||
if (!this.filePath) return;
|
||||
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
|
||||
var r = await fetch(this.apiBase() + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (r.ok) {
|
||||
@@ -403,7 +518,7 @@ var _regGiteaWs = () => {
|
||||
|
||||
async loadPrivatePages() {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
||||
var r = await fetch(this.apiBase() + '/private-pages');
|
||||
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
@@ -416,7 +531,7 @@ var _regGiteaWs = () => {
|
||||
|
||||
async openPrivate(id) {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
|
||||
var r = await fetch(this.apiBase() + '/private-pages/' + id);
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.editingPrivate = id;
|
||||
@@ -427,7 +542,7 @@ var _regGiteaWs = () => {
|
||||
},
|
||||
|
||||
async savePrivate() {
|
||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
|
||||
var url = this.apiBase() + '/private-pages';
|
||||
var method = 'POST';
|
||||
if (this.editingPrivate !== 'new') {
|
||||
url += '/' + this.editingPrivate;
|
||||
@@ -455,7 +570,7 @@ var _regGiteaWs = () => {
|
||||
async createNewFile() {
|
||||
if (!this.newFilePath.trim()) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
var r = await fetch(this.apiBase() + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
|
||||
@@ -483,7 +598,7 @@ var _regGiteaWs = () => {
|
||||
form.append('file', files[i]);
|
||||
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
|
||||
var r = await fetch(this.apiBase() + '/upload', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: form
|
||||
@@ -498,7 +613,7 @@ var _regGiteaWs = () => {
|
||||
async deletePrivate(id) {
|
||||
if (!confirm('Delete this private page?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
|
||||
var r = await fetch(this.apiBase() + '/private-pages/' + id, {
|
||||
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
||||
});
|
||||
if (r.ok) await this.loadPrivatePages();
|
||||
@@ -625,7 +740,7 @@ var _regGiteaWs = () => {
|
||||
var parts = path.split('/');
|
||||
parts.pop();
|
||||
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
|
||||
fetch(this.apiBase() + '/rename', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
|
||||
|
||||
@@ -155,6 +155,16 @@ function workspacesPage() {
|
||||
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
|
||||
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
||||
}
|
||||
},
|
||||
openGithub(proj) {
|
||||
const full = proj.full_name || '';
|
||||
const parts = full.split('/');
|
||||
const owner = proj.owner?.login || parts[0] || '';
|
||||
const repo = proj.name || parts[1] || '';
|
||||
if (owner && repo) {
|
||||
document.cookie = 'flowdeck_workspace=github:' + owner + ':' + repo + ';path=/;SameSite=Lax';
|
||||
window.location = `/github-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user