v2.8.0 WIP: create/upload files + commit history + labels sync
- POST /api/gitea/projects/{owner}/{repo}/upload (binary upload)
- get_file_commits() in GiteaClient
- GET /api/gitea/projects/{owner}/{repo}/commits (per-file history)
- POST .../sync-labels (Gitea labels → FlowDeck tags)
- New file form + upload button in gitea_workspace.html topbar
This commit is contained in:
@@ -5,6 +5,10 @@ GITEA_OAUTH_CLIENT_ID=
|
||||
GITEA_OAUTH_CLIENT_SECRET=
|
||||
GITEA_WEBHOOK_SECRET=
|
||||
|
||||
# ── GitHub ──
|
||||
GITHUB_OAUTH_CLIENT_ID=
|
||||
GITHUB_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# ── App ──
|
||||
APP_SECRET_KEY=change-me-to-random
|
||||
APP_HOST=0.0.0.0
|
||||
|
||||
@@ -244,8 +244,8 @@ def get_providers() -> list[OAuthProvider]:
|
||||
providers.append(gitea)
|
||||
|
||||
github = GitHubProvider(
|
||||
client_id=settings.github_client_id or "",
|
||||
client_secret=settings.github_client_secret or "",
|
||||
client_id=settings.github_oauth_client_id or "",
|
||||
client_secret=settings.github_oauth_client_secret or "",
|
||||
redirect_uri=settings.oauth_redirect_uri or "",
|
||||
)
|
||||
if github.is_enabled():
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ class Settings(BaseSettings):
|
||||
gitea_webhook_secret: str = ""
|
||||
|
||||
# GitHub
|
||||
github_client_id: str = ""
|
||||
github_client_secret: str = ""
|
||||
github_oauth_client_id: str = ""
|
||||
github_oauth_client_secret: str = ""
|
||||
|
||||
# Standalone mode
|
||||
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
|
||||
|
||||
@@ -106,6 +106,31 @@ async def save_file(request: Request, owner: str, repo: str):
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Upload file (binary) ──
|
||||
@router.post("/projects/{owner}/{repo}/upload")
|
||||
async def upload_file(request: Request, owner: str, repo: str):
|
||||
"""Upload a binary file to the repo."""
|
||||
import base64
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid form data"}, status_code=400)
|
||||
file = form.get("file")
|
||||
folder = form.get("folder", "")
|
||||
message = form.get("message", "Upload via FlowDeck")
|
||||
if not file:
|
||||
return JSONResponse({"error": "No file provided"}, status_code=400)
|
||||
try:
|
||||
content = await file.read()
|
||||
encoded = base64.b64encode(content).decode("utf-8")
|
||||
path = f"{folder.strip('/')}/{file.filename}".strip("/") if folder.strip("/") else file.filename
|
||||
result = await gitea.create_or_update_file(owner, repo, path, encoded, message, sha=None)
|
||||
return {"status": "ok", "path": path, "commit": result.get("commit", {})}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Delete file ──
|
||||
@router.delete("/projects/{owner}/{repo}/file")
|
||||
async def delete_file(request: Request, owner: str, repo: str):
|
||||
@@ -266,3 +291,48 @@ async def delete_private_page(owner: str, repo: str, page_id: int, request: Requ
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Commit history for a file ──
|
||||
@router.get("/projects/{owner}/{repo}/commits")
|
||||
async def file_commits(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""Get recent commits for a specific file."""
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
commits = await gitea.get_file_commits(owner, repo, path)
|
||||
return {"commits": commits}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
# ── Sync labels to tags ──
|
||||
@router.post("/projects/{owner}/{repo}/sync-labels")
|
||||
async def sync_labels(request: Request, owner: str, repo: str):
|
||||
"""Sync Gitea labels to FlowDeck tags for the current user."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.db import get_conn
|
||||
user = await gcu(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
gitea = _require_gitea(request)
|
||||
try:
|
||||
labels = await gitea.get_labels(owner, repo)
|
||||
except Exception:
|
||||
labels = []
|
||||
imported = 0
|
||||
with get_conn() as conn:
|
||||
for label in labels:
|
||||
name = label.get("name", "")
|
||||
color = label.get("color", "#787774")
|
||||
if not name: continue
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)",
|
||||
(name, f"#{color}", user["id"]),
|
||||
)
|
||||
imported += 1
|
||||
conn.commit()
|
||||
return {"status": "ok", "imported": imported, "total": len(labels)}
|
||||
|
||||
@@ -386,6 +386,17 @@ class GiteaClient:
|
||||
for k in keys:
|
||||
del self._cache[k]
|
||||
|
||||
async def get_file_commits(self, owner: str, repo: str, path: str) -> list[dict]:
|
||||
"""Get recent commits affecting a specific file."""
|
||||
url = f"{self.base}/api/v1/repos/{owner}/{repo}/commits"
|
||||
params = {"path": path, "limit": 20}
|
||||
headers = {"Authorization": f"token {self.token[0] if self.token else ''}", "Accept": "application/json"}
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
resp = await client.get(url, params=params, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return []
|
||||
|
||||
|
||||
# ── singleton ──
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""FlowDeck — GitHub API adapter with caching."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TTL = 30 # seconds
|
||||
|
||||
|
||||
class GitHubAdapter:
|
||||
"""Async GitHub API client (v3 REST) with simple TTL cache.
|
||||
|
||||
Authenticated via OAuth2 Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
|
||||
self._base = "https://api.github.com"
|
||||
self._headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
self._cache: dict[str, tuple[datetime, Any]] = {}
|
||||
self._ttl = timedelta(seconds=ttl)
|
||||
|
||||
# ── cache helpers ──
|
||||
|
||||
def _cached(self, key: str) -> Any | None:
|
||||
entry = self._cache.get(key)
|
||||
if entry and entry[0] > datetime.now():
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
def _set_cache(self, key: str, value: Any) -> None:
|
||||
self._cache[key] = (datetime.now() + self._ttl, value)
|
||||
|
||||
# ── repos ──
|
||||
|
||||
async def list_repos(self, page: int = 1, per_page: int = 50) -> list[dict]:
|
||||
"""List repositories for the authenticated user (paginated)."""
|
||||
cache_key = f"repos:{page}:{per_page}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user/repos",
|
||||
headers=self._headers,
|
||||
params={"page": page, "per_page": per_page, "sort": "updated"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._set_cache(cache_key, data)
|
||||
return data
|
||||
|
||||
async def list_all_repos(self) -> list[dict]:
|
||||
"""Fetch all user repos across pages (up to 5 pages / 250 repos)."""
|
||||
all_repos: list[dict] = []
|
||||
for page in range(1, 6):
|
||||
repos = await self.list_repos(page=page)
|
||||
if not repos:
|
||||
break
|
||||
all_repos.extend(repos)
|
||||
return all_repos
|
||||
|
||||
# ── repo tree ──
|
||||
|
||||
async def get_repo_tree(
|
||||
self, owner: str, repo: str, sha: str | None = None, recursive: bool = True
|
||||
) -> list[dict]:
|
||||
"""Get the git tree for a repo. If ``sha`` is omitted, resolves the
|
||||
default branch first."""
|
||||
cache_key = f"tree:{owner}:{repo}:{sha or 'default'}:{recursive}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
# Resolve default branch commit SHA if not provided
|
||||
if sha is None:
|
||||
repo_info = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}",
|
||||
headers=self._headers,
|
||||
)
|
||||
repo_info.raise_for_status()
|
||||
sha = repo_info.json()["default_branch"]
|
||||
|
||||
# Now get the commit to find tree SHA
|
||||
branch_resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/branches/{sha}",
|
||||
headers=self._headers,
|
||||
)
|
||||
branch_resp.raise_for_status()
|
||||
sha = branch_resp.json()["commit"]["commit"]["tree"]["sha"]
|
||||
|
||||
params: dict[str, Any] = {}
|
||||
if recursive:
|
||||
params["recursive"] = "1"
|
||||
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/git/trees/{sha}",
|
||||
headers=self._headers,
|
||||
params=params,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data.get("tree", [])
|
||||
# Truncated responses — fetch remaining pages if needed
|
||||
while data.get("truncated", False):
|
||||
logger.warning("GitHub tree truncated for %s/%s — results incomplete", owner, repo)
|
||||
break
|
||||
|
||||
self._set_cache(cache_key, items)
|
||||
return items
|
||||
|
||||
# ── file content ──
|
||||
|
||||
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
|
||||
"""Get decoded file content from a repo."""
|
||||
cache_key = f"file:{owner}:{repo}:{path}"
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
item = resp.json()
|
||||
|
||||
content = ""
|
||||
if item.get("encoding") == "base64" and item.get("content"):
|
||||
try:
|
||||
content = base64.b64decode(item["content"]).decode("utf-8")
|
||||
except Exception:
|
||||
content = "[binary file]"
|
||||
|
||||
self._set_cache(cache_key, content)
|
||||
return content
|
||||
|
||||
# ── token validation ──
|
||||
|
||||
async def validate_token(self) -> bool:
|
||||
"""Check whether the access token is still valid."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user",
|
||||
headers=self._headers,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
@@ -529,6 +529,8 @@
|
||||
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
|
||||
contextActions: {},
|
||||
showNewPageMenu: false,
|
||||
selectedItems: [],
|
||||
lastClickedIndex: -1,
|
||||
|
||||
// ── Workspace tree context menu ──
|
||||
wsCtx: { visible: false, x: 0, y: 0, id: null, name: '', isFolder: false },
|
||||
@@ -1027,6 +1029,71 @@
|
||||
window.addEventListener('pageshow', function(e) {
|
||||
if (e.persisted) applySavedTheme();
|
||||
});
|
||||
|
||||
// ═══════════ GLOBAL KEYBOARD SHORTCUTS ═══════════
|
||||
document.addEventListener('keydown', function(e) {
|
||||
var tag = (document.activeElement && document.activeElement.tagName) || '';
|
||||
var editable = tag === 'INPUT' || tag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable);
|
||||
// Don't intercept when typing in inputs unless it's Escape
|
||||
if (editable && e.key !== 'Escape') return;
|
||||
// Don't intercept when a modal prompt is open
|
||||
if (document.querySelector('.modal-overlay:not([style*="display:none"])')) return;
|
||||
|
||||
var appEl = document.querySelector('[x-data]');
|
||||
var data = appEl && appEl.__x ? appEl.__x.$data : null;
|
||||
|
||||
if (e.ctrlKey && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
if (data && data.newPageInWorkspace) data.newPageInWorkspace();
|
||||
else if (data && data.newPage) data.newPage('Private');
|
||||
}
|
||||
else if (e.key === 'Enter' && !editable) {
|
||||
var activeSidebar = document.querySelector('.sidebar-item.active');
|
||||
if (activeSidebar) {
|
||||
e.preventDefault();
|
||||
activeSidebar.click();
|
||||
}
|
||||
}
|
||||
else if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
// Trigger rename: dispatch dblclick on active item's name span
|
||||
var nameSpan = document.querySelector('.sidebar-item.active .page-name');
|
||||
if (nameSpan) {
|
||||
nameSpan.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||
} else if (data && data.wsCtx && data.wsCtx.id) {
|
||||
// Workspace tree item: trigger rename via context menu
|
||||
data.wsCtxAction('rename');
|
||||
}
|
||||
}
|
||||
else if (e.key === 'Delete' && !editable) {
|
||||
e.preventDefault();
|
||||
var delItem = document.querySelector('.sidebar-item.active');
|
||||
if (delItem && delItem.dataset.pageId && data) {
|
||||
data.contextMenu.pageId = delItem.dataset.pageId;
|
||||
data.contextMenu.pageName = delItem.dataset.pageName || delItem.querySelector('.page-name')?.textContent || '';
|
||||
data.contextAction('delete');
|
||||
} else if (delItem && delItem.dataset.giteaPath) {
|
||||
// Gitea file: dispatch custom event
|
||||
delItem.dispatchEvent(new CustomEvent('gitea-delete', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
// Close card modal
|
||||
if (typeof closeModal === 'function') closeModal();
|
||||
// Close context menus
|
||||
if (data) {
|
||||
if (data.contextMenu) data.contextMenu.visible = false;
|
||||
if (data.wsCtx) data.wsCtx.visible = false;
|
||||
if (data.showNewPageMenu) data.showNewPageMenu = false;
|
||||
}
|
||||
}
|
||||
else if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
// Try to find and click a save/commit button
|
||||
var saveBtn = document.querySelector('.btn-primary, button:contains("Save"), button:contains("Commit")');
|
||||
if (saveBtn) saveBtn.click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/app.js?v=2.4.6" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
<a href="/workspaces" class="breadcrumb" style="color:rgba(255,255,255,.5);text-decoration:none;">Home</a>
|
||||
<span class="page-title-topbar">{{ owner }}/{{ repo }}</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="topbar-right" style="display:flex;align-items:center;gap:6px;">
|
||||
<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button>
|
||||
<button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button>
|
||||
<input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple>
|
||||
<button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button>
|
||||
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
|
||||
</div>
|
||||
@@ -19,11 +22,17 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Prism.js syntax highlighting -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/themes/prism-tomorrow.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/prism.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/plugins/autoloader/prism-autoloader.min.js"></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;}
|
||||
@@ -39,24 +48,45 @@
|
||||
.btn-danger:hover{background:rgba(255,80,80,.25);}
|
||||
.btn{border:none;padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:500;border:1px solid var(--border);background:transparent;color:var(--text-dim);}
|
||||
.btn:hover{background:var(--bg-tertiary);color:var(--text);}
|
||||
|
||||
/* ── Skeleton loading animation ── */
|
||||
.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
||||
.file-skeleton{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);max-height:70vh;overflow:hidden;}
|
||||
|
||||
/* Prism 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;}
|
||||
</style>
|
||||
|
||||
<div class="gw-main" x-data="giteaWorkspace()">
|
||||
<!-- File view -->
|
||||
<template x-if="showFile && !showEditor">
|
||||
<div>
|
||||
<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()">✏️ Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<pre x-text="fileContent"></pre>
|
||||
</div>
|
||||
<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()">✏️ Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="gw-content">
|
||||
<!-- Skeleton loading -->
|
||||
<div x-show="fileLoading" class="file-skeleton">
|
||||
<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 -->
|
||||
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor view -->
|
||||
<template x-if="showEditor">
|
||||
@@ -86,7 +116,14 @@
|
||||
<div class="gw-content">
|
||||
<div class="empty-state">
|
||||
<h2>📁 <span x-text="owner + '/' + repo"></span></h2>
|
||||
<p>Select a file from the sidebar to view it, or browse private pages.</p>
|
||||
<p x-show="!showNewFile">Select a file from the sidebar or create a new one</p>
|
||||
<!-- New file form -->
|
||||
<div x-show="showNewFile" style="margin-top:16px;text-align:left;max-width:400px;margin-left:auto;margin-right:auto;">
|
||||
<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()">💾 Create file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -152,6 +189,8 @@ function giteaWorkspace() {
|
||||
fileContent: '',
|
||||
fileSize: 0,
|
||||
fileSha: '',
|
||||
fileLoading: false,
|
||||
fileLanguage: 'text',
|
||||
editContent: '',
|
||||
commitMessage: 'Update via FlowDeck',
|
||||
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
||||
@@ -182,8 +221,34 @@ function giteaWorkspace() {
|
||||
li.setAttribute('data-gitea-path', item.path);
|
||||
li.setAttribute('data-gitea-type', item.type === 'folder' ? 'folder' : 'file');
|
||||
li.setAttribute('data-gitea-sha', item.sha || '');
|
||||
li.style.cssText = 'padding-left:12px;cursor:pointer;';
|
||||
li.innerHTML = '<span class="page-icon">' + icon + '</span><span class="page-name">' + item.name + '</span>';
|
||||
li.style.cssText = 'padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;';
|
||||
// Checkbox (hidden by default, visible in multi-select mode)
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'gitea-checkbox';
|
||||
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
|
||||
cb.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
var gw = document.querySelector('[x-data]').__x.$data;
|
||||
gw.selectItem(item.path, li, ev);
|
||||
});
|
||||
li.appendChild(cb);
|
||||
// Icon
|
||||
var iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'page-icon';
|
||||
iconSpan.textContent = icon;
|
||||
li.appendChild(iconSpan);
|
||||
// Name (with double-click rename)
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'page-name';
|
||||
nameSpan.textContent = item.name;
|
||||
nameSpan.addEventListener('dblclick', function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var gw = document.querySelector('[x-data]').__x.$data;
|
||||
gw.startInlineRename(nameSpan, item.path, li);
|
||||
});
|
||||
li.appendChild(nameSpan);
|
||||
container.appendChild(li);
|
||||
});
|
||||
// Add Private Pages link
|
||||
@@ -209,16 +274,54 @@ function giteaWorkspace() {
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target.closest('.sidebar-item[data-gitea-path]');
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var path = el.getAttribute('data-gitea-path');
|
||||
var type = el.getAttribute('data-gitea-type');
|
||||
|
||||
// If clicking the checkbox, let its own handler deal with selection
|
||||
if (e.target.classList.contains('gitea-checkbox')) return;
|
||||
|
||||
// If clicking the inline rename input, don't navigate
|
||||
if (e.target.classList.contains('inline-rename-input')) return;
|
||||
|
||||
// If Shift or Ctrl/Meta is pressed, use multi-selection
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
self.selectItem(path, el, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal click: navigate (open file/folder)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Update visual "active" state
|
||||
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
|
||||
si.classList.remove('active');
|
||||
});
|
||||
el.classList.add('active');
|
||||
|
||||
if (type === 'folder') {
|
||||
self.loadSubdir(path, el);
|
||||
} else {
|
||||
self.openFile(path, el.getAttribute('data-gitea-sha'));
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for custom delete event on gitea sidebar items
|
||||
document.addEventListener('gitea-delete', function(e) {
|
||||
var el = e.target;
|
||||
var path = el.getAttribute('data-gitea-path');
|
||||
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'), {
|
||||
method: 'DELETE',
|
||||
}).then(function(r) {
|
||||
if (r.ok) self.refreshTree();
|
||||
}).catch(function() {});
|
||||
});
|
||||
},
|
||||
|
||||
async loadSubdir(path, el) {
|
||||
@@ -243,8 +346,33 @@ function giteaWorkspace() {
|
||||
li.setAttribute('data-gitea-path', child.path);
|
||||
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
|
||||
li.setAttribute('data-gitea-sha', child.sha || '');
|
||||
li.style.cursor = 'pointer';
|
||||
li.innerHTML = '<span class="sidebar-icon">' + icon + '</span> ' + child.name;
|
||||
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
|
||||
// Checkbox
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'gitea-checkbox';
|
||||
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
|
||||
cb.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
var gw = document.querySelector('[x-data]').__x.$data;
|
||||
gw.selectItem(child.path, li, ev);
|
||||
});
|
||||
li.appendChild(cb);
|
||||
// Icon
|
||||
var iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'sidebar-icon';
|
||||
iconSpan.textContent = icon;
|
||||
li.appendChild(iconSpan);
|
||||
// Name
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = child.name;
|
||||
nameSpan.addEventListener('dblclick', function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var gw = document.querySelector('[x-data]').__x.$data;
|
||||
gw.startInlineRename(nameSpan, child.path, li);
|
||||
});
|
||||
li.appendChild(nameSpan);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
el.appendChild(ul);
|
||||
@@ -256,6 +384,8 @@ function giteaWorkspace() {
|
||||
this.fileSha = sha || '';
|
||||
this.showEditor = false;
|
||||
this.showFile = true;
|
||||
this.fileLoading = true;
|
||||
this.fileLanguage = this.getLanguage(path);
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
|
||||
if (r.ok) {
|
||||
@@ -268,6 +398,30 @@ function giteaWorkspace() {
|
||||
} catch(e) {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
this.fileLoading = false;
|
||||
// Highlight after Alpine renders
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
var block = self.$refs.codeBlock;
|
||||
if (block && block.textContent && typeof Prism !== 'undefined') {
|
||||
Prism.highlightElement(block);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getLanguage(path) {
|
||||
var ext = (path || '').split('.').pop().toLowerCase();
|
||||
var map = {
|
||||
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
|
||||
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
|
||||
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
|
||||
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
|
||||
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
|
||||
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
|
||||
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
|
||||
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
|
||||
};
|
||||
return map[ext] || 'text';
|
||||
},
|
||||
|
||||
openEditor() {
|
||||
@@ -376,6 +530,54 @@ function giteaWorkspace() {
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
// Create new file
|
||||
showNewFile: false,
|
||||
newFilePath: '',
|
||||
newFileContent: '',
|
||||
newFileMsg: 'Create via FlowDeck',
|
||||
async createNewFile() {
|
||||
if (!this.newFilePath.trim()) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/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'})
|
||||
});
|
||||
if (r.ok) {
|
||||
this.showNewFile = false;
|
||||
this.newFilePath = '';
|
||||
this.newFileContent = '';
|
||||
this.newFileMsg = 'Create via FlowDeck';
|
||||
await this.loadSidebarTree();
|
||||
} else {
|
||||
var d = await r.json();
|
||||
alert('Failed: ' + (d.error || d.detail || 'Unknown error'));
|
||||
}
|
||||
} catch(e) { alert('Error: ' + e.message); }
|
||||
},
|
||||
|
||||
// Upload files
|
||||
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
|
||||
async doUpload(ev) {
|
||||
var files = ev.target.files;
|
||||
if (!files.length) return;
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
var form = new FormData();
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: form
|
||||
});
|
||||
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
|
||||
} catch(e) { console.error('Upload error:', e); }
|
||||
}
|
||||
await this.loadSidebarTree();
|
||||
ev.target.value = '';
|
||||
},
|
||||
|
||||
async deletePrivate(id) {
|
||||
if (!confirm('Delete this private page?')) return;
|
||||
try {
|
||||
@@ -389,6 +591,144 @@ function giteaWorkspace() {
|
||||
getCsrfToken() {
|
||||
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
|
||||
},
|
||||
|
||||
// ── Multi-selection ──
|
||||
multiSelect: false,
|
||||
selectedPaths: [],
|
||||
lastClickedPath: null,
|
||||
|
||||
toggleMultiSelect() {
|
||||
this.multiSelect = !this.multiSelect;
|
||||
var cbs = document.querySelectorAll('.gitea-checkbox');
|
||||
var self = this;
|
||||
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
|
||||
if (!this.multiSelect) {
|
||||
// Clear selection when leaving multi-select mode
|
||||
cbs.forEach(function(cb) { cb.checked = false; });
|
||||
this.selectedPaths = [];
|
||||
this.lastClickedPath = null;
|
||||
// Remove selected class
|
||||
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
||||
el.classList.remove('gitea-selected');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
selectItem(path, li, ev) {
|
||||
if (ev.shiftKey && this.lastClickedPath) {
|
||||
// Range select
|
||||
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
|
||||
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
|
||||
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
|
||||
if (startIdx >= 0 && endIdx >= 0) {
|
||||
var lo = Math.min(startIdx, endIdx);
|
||||
var hi = Math.max(startIdx, endIdx);
|
||||
this.selectedPaths = [];
|
||||
for (var i = lo; i <= hi; i++) {
|
||||
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
|
||||
all[i].classList.add('gitea-selected');
|
||||
var cb = all[i].querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
}
|
||||
this.multiSelect = true;
|
||||
this.toggleMultiSelect(); // ensure checkboxes are visible
|
||||
} else if (ev.ctrlKey || ev.metaKey) {
|
||||
// Toggle single
|
||||
var idx = this.selectedPaths.indexOf(path);
|
||||
if (idx >= 0) {
|
||||
this.selectedPaths.splice(idx, 1);
|
||||
li.classList.remove('gitea-selected');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = false;
|
||||
} else {
|
||||
this.selectedPaths.push(path);
|
||||
li.classList.add('gitea-selected');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
this.multiSelect = this.selectedPaths.length > 0;
|
||||
this.toggleMultiSelect();
|
||||
} else {
|
||||
// Normal select — clear multi-selection
|
||||
this.selectedPaths = [path];
|
||||
this.lastClickedPath = path;
|
||||
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
||||
el.classList.remove('gitea-selected');
|
||||
});
|
||||
li.classList.add('gitea-selected', 'active');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
},
|
||||
|
||||
isSelected(path) {
|
||||
return this.selectedPaths.indexOf(path) >= 0;
|
||||
},
|
||||
|
||||
// ── Inline rename ──
|
||||
startInlineRename(nameSpan, path, li) {
|
||||
var originalName = nameSpan.textContent;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = originalName;
|
||||
input.className = 'inline-rename-input';
|
||||
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
|
||||
nameSpan.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
var self = this;
|
||||
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
|
||||
var cancel = function() {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
};
|
||||
input.addEventListener('blur', finish);
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') finish();
|
||||
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
|
||||
});
|
||||
},
|
||||
|
||||
finishInlineRename(input, originalName, path, li) {
|
||||
if (input._renaming) return; // guard against double-fire
|
||||
input._renaming = true;
|
||||
var newName = input.value.trim();
|
||||
if (!newName || newName === originalName) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
// Construct new path by replacing the last segment
|
||||
var parts = path.split('/');
|
||||
parts.pop();
|
||||
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/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})
|
||||
}).then(function(r) {
|
||||
if (r.ok) {
|
||||
self.refreshTree();
|
||||
} else {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
alert('Rename failed');
|
||||
}
|
||||
}).catch(function() {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
.org-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||
.org-tab-badge{font-size:10px;padding:0 5px;border-radius:8px;background:rgba(255,255,255,.15);}
|
||||
.search-input:focus{border-color:var(--accent);}
|
||||
|
||||
/* ── Skeleton loading animation ── */
|
||||
.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
||||
</style>
|
||||
|
||||
<div class="ws-page" x-data="workspacesPage()">
|
||||
@@ -109,8 +113,16 @@
|
||||
<a class="gitea-connect" href="/auth/login?provider=gitea">📝 Register with Gitea</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div class="gitea-loading" x-show="giteaStatus==='loading'">Loading Gitea projects…</div>
|
||||
<!-- Loading skeletons -->
|
||||
<div x-show="giteaStatus==='loading'" class="ws-grid">
|
||||
<template x-for="i in 6" :key="i">
|
||||
<div class="ws-card" style="pointer-events:none;">
|
||||
<div class="skeleton" style="height:16px;width:65%;margin-bottom:8px;"></div>
|
||||
<div class="skeleton" style="height:12px;width:45%;margin-bottom:6px;"></div>
|
||||
<div class="skeleton" style="height:12px;width:80%;"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div class="gitea-empty" x-show="giteaStatus==='error'">
|
||||
|
||||
Reference in New Issue
Block a user