feat: sidebar arborescence Gitea + tab user repos avec vrai username
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped

1. Tab user repos: fetch username via /auth/user → affiche 'bruno' au lieu de 'Personal'
2. Sidebar: ws_cookie='gitea:owner:repo' → _sidebar_data() fetch le tree depuis Gitea API
3. base.html: data-gitea-path/type/sha sur les items workspace pour les projets Gitea
4. gitea_workspace.html: refonte sans tree panel → navigation via sidebar
   - Clic fichier → ouvre dans le contenu (lecture + edit + commit)
   - Clic dossier → lazy-load les enfants dans le sidebar
5. openGitea(): set cookie flowdeck_workspace avant navigation
This commit is contained in:
2026-07-13 16:42:11 -04:00
parent 5fe31aeffa
commit e78ca4b775
4 changed files with 172 additions and 215 deletions
+36 -1
View File
@@ -69,7 +69,41 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict:
ws_cookie = request.cookies.get("flowdeck_workspace", "")
active_ws_name = "Workspace"
workspace_pages = []
if ws_cookie:
gitea_workspace = False
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: fetch repo tree for sidebar
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
# Fetch root tree from Gitea API (use admin token as fallback)
try:
from app.services.gitea_client import get_user_gitea_client, GiteaClient
gitea = get_user_gitea_client(request) or GiteaClient()
import asyncio
tree_items = asyncio.run(gitea.get_repo_contents(gitea_owner, gitea_repo))
for item in tree_items:
is_folder = item.get("type") == "dir"
workspace_pages.append({
"id": item.get("path", ""),
"name": item.get("name", ""),
"icon": "📁" if is_folder else "📄",
"url": f"javascript:void(0)",
"active": False,
"indent": 0,
"depth": 0,
"has_children": is_folder,
"children": [],
"_gitea_path": item.get("path", ""),
"_gitea_type": "folder" if is_folder else "file",
"_gitea_sha": item.get("sha", ""),
})
except Exception:
pass
elif ws_cookie:
workspace_pages = _load_workspace_pages(ws_cookie)
try:
with get_conn() as conn:
@@ -83,6 +117,7 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict:
"workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name,
"workspace_pages": workspace_pages,
"gitea_workspace": gitea_workspace,
"current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages,
"private_pages": [],
+4 -1
View File
@@ -28,7 +28,10 @@
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
data-page-id="{{ page.id }}"
data-page-depth="{{ depth }}"
data-has-children="{{ 'true' if page.has_children else 'false' }}">
data-has-children="{{ 'true' if page.has_children else 'false' }}"
{% if page._gitea_path %}data-gitea-path="{{ page._gitea_path }}"{% endif %}
{% if page._gitea_type %}data-gitea-type="{{ page._gitea_type }}"{% endif %}
{% if page._gitea_sha %}data-gitea-sha="{{ page._gitea_sha }}"{% endif %}>
{# Toggle chevron — only visible for items with children #}
{% if page.has_children %}
<button class="tree-toggle"
+118 -212
View File
@@ -12,7 +12,7 @@
<span class="page-title-topbar" x-text="owner + '/' + repo"></span>
</div>
<div class="topbar-right">
<button class="page-action-btn" @click="refresh()" title="Refresh">🔄</button>
<button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button>
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
@@ -20,16 +20,7 @@
{% block content %}
<style>
.gw-layout{display:flex;height:calc(100vh - 48px);}
.gw-tree{width:280px;min-width:280px;background:var(--bg-sidebar);border-right:1px solid var(--border);overflow-y:auto;padding:12px 0;}
.gw-tree-item{display:flex;align-items:center;gap:6px;padding:4px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.gw-tree-item:hover{background:var(--bg-tertiary);color:var(--text);}
.gw-tree-item.selected{background:var(--accent);color:#fff;}
.gw-tree-icon{font-size:14px;flex-shrink:0;}
.gw-tree-indent{padding-left:32px !important;}
.gw-tree-indent2{padding-left:48px !important;}
.gw-tree-section-title{font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;padding:8px 16px 4px;margin-top:8px;border-top:1px solid var(--border);}
.gw-main{flex:1;display:flex;flex-direction:column;overflow:hidden;}
.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;}
@@ -37,136 +28,68 @@
.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);}
.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-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);}
.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;}
.btn-primary{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);}
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);border:none;padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:500;}
.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);}
.loading-spinner{text-align:center;padding:20px;color:var(--text-dim);}
</style>
<div class="gw-layout" x-data="giteaWorkspace()">
<!-- Tree sidebar -->
<div class="gw-tree">
<div class="gw-tree-section-title">📁 GITEA REPO</div>
<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>
</template>
<!-- Loading -->
<div class="loading-spinner" x-show="loading">Loading…</div>
<!-- Tree items (recursive rendering) -->
<template x-for="item in treeItems" :key="item.path">
<div>
<div class="gw-tree-item"
:class="{selected: item.path === selectedPath}"
:style="'padding-left:' + (16 + item.depth * 16) + 'px'"
@click="item.type==='folder' ? toggleFolder(item) : openFile(item)">
<span class="gw-tree-icon" x-text="item.type==='folder' ? (item.expanded ? '📂' : '📁') : '📄'"></span>
<span x-text="item.name" style="overflow:hidden;text-overflow:ellipsis;"></span>
</div>
<!-- children -->
<template x-if="item.expanded">
<template x-for="child in item.children" :key="child.path">
<div class="gw-tree-item"
:class="{selected: child.path === selectedPath}"
:style="'padding-left:' + (16 + child.depth * 16) + 'px'"
@click="child.type==='folder' ? toggleFolder(child) : openFile(child)">
<span class="gw-tree-icon" x-text="child.type==='folder' ? (child.expanded ? '📂' : '📁') : '📄'"></span>
<span x-text="child.name" style="overflow:hidden;text-overflow:ellipsis;"></span>
</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>
</template>
</datalist>
<button class="btn-primary" @click="saveFile()">💾 Commit</button>
</div>
</template>
<!-- Private section -->
<div class="gw-tree-section-title">🔒 PRIVATE PAGES</div>
<div class="gw-tree-item" @click="selectedPath='--private--'; showEditor=false; showFile=false;">
<span class="gw-tree-icon">📝</span>
<span>My notes</span>
</div>
<div class="gw-tree-item" @click="newPrivatePage()" style="color:var(--accent);">
<span class="gw-tree-icon">➕</span>
<span>New page</span>
</template>
<!-- Empty state -->
<template x-if="!showFile && !showEditor">
<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.</p>
</div>
</div>
</div>
<!-- Main content -->
<div class="gw-main">
<!-- Private pages view -->
<template x-if="selectedPath==='--private--'">
<div class="gw-content">
<template x-if="privatePages.length === 0">
<div class="empty-state">
<h2>🔒 Private Pages</h2>
<p>Create FlowDeck pages attached to this Gitea project. These are stored locally, not in the repo.</p>
<button class="btn btn-primary" @click="newPrivatePage()" style="margin-top:12px;">+ New Page</button>
</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;"
@click="openPrivatePage(pp)">
<div style="font-weight:500;margin-bottom:4px;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? 'Updated ' + new Date(pp.updated_at).toLocaleDateString() : ''"></div>
</div>
</template>
</div>
</template>
<!-- File view -->
<template x-if="showFile && !showEditor">
<div>
<div class="gw-file-toolbar">
<strong x-text="currentFile?.name || ''"></strong>
<span x-text="formatSize(currentFile?.size || 0)"></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>
</template>
<!-- Editor view -->
<template x-if="showEditor">
<div>
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="currentFile?.name || '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()">💾 Commit</button>
</div>
</div>
</template>
<!-- Empty state -->
<template x-if="!showFile && !showEditor && selectedPath!=='--private--'">
<div class="gw-content">
<div class="empty-state">
<h2>📁 <span x-text="owner + '/' + repo"></span></h2>
<p>Select a file from the tree to view it, or create a private page.</p>
</div>
</div>
</template>
</div>
</template>
</div>
<script>
@@ -177,81 +100,81 @@ function giteaWorkspace() {
return {
owner, repo,
treeItems: [],
selectedPath: '',
currentFile: null,
fileContent: '',
showFile: false,
showEditor: false,
filePath: '',
fileContent: '',
fileSize: 0,
fileSha: '',
editContent: '',
commitMessage: 'Update via FlowDeck',
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
loading: true,
privatePages: [],
async init() {
await this.loadRoot();
await this.loadPrivatePages();
init() {
// Set cookie for sidebar
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
// Listen for sidebar clicks on Gitea items
this.setupSidebarClicks();
},
async refresh() {
this.treeItems = [];
this.loading = true;
await this.loadRoot();
},
async loadRoot() {
this.loading = true;
try {
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/tree`);
if (r.ok) {
const d = await r.json();
this.treeItems = (d.tree || []).map(item => ({
...item,
depth: 0,
expanded: false,
children: [],
}));
setupSidebarClicks() {
var self = this;
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 (type === 'folder') {
self.loadSubdir(path, el);
} else {
self.openFile(path, el.getAttribute('data-gitea-sha'));
}
} catch(e) {}
this.loading = false;
});
},
async toggleFolder(item) {
if (item.expanded) {
item.expanded = false;
async loadSubdir(path, el) {
// Check if already loaded
var ul = el.querySelector('ul');
if (ul) {
ul.style.display = ul.style.display === 'none' ? '' : 'none';
return;
}
if (item.children.length > 0) {
item.expanded = true;
return;
}
// Lazy load
try {
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/tree?path=${encodeURIComponent(item.path)}`);
if (r.ok) {
const d = await r.json();
item.children = (d.tree || []).map(child => ({
...child,
depth: item.depth + 1,
expanded: false,
children: [],
}));
}
item.expanded = true;
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/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';
children.forEach(function(child) {
var icon = child.type === 'folder' ? '📁' : '📄';
var li = document.createElement('li');
li.className = 'sidebar-item';
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;
ul.appendChild(li);
});
el.appendChild(ul);
} catch(e) {}
},
async openFile(item) {
this.selectedPath = item.path;
this.currentFile = item;
async openFile(path, sha) {
this.filePath = path;
this.fileSha = sha || '';
this.showEditor = false;
this.showFile = true;
try {
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/file?path=${encodeURIComponent(item.path)}`);
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
if (r.ok) {
const d = await r.json();
var d = await r.json();
this.fileContent = d.content || '';
this.fileSize = d.content ? d.content.length : 0;
} else {
this.fileContent = '[Error loading file]';
}
@@ -266,74 +189,57 @@ function giteaWorkspace() {
},
async saveFile() {
if (!this.currentFile) return;
const path = this.currentFile.path;
if (!this.filePath) return;
try {
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/file`, {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
path, content: this.editContent,
message: this.commitMessage,
sha: this.currentFile.sha,
path: this.filePath, content: this.editContent,
message: this.commitMessage, sha: this.fileSha,
})
});
if (r.ok) {
this.fileContent = this.editContent;
this.showEditor = false;
// Save commit message history
if (!this.commitHistory.includes(this.commitMessage)) {
this.commitHistory.unshift(this.commitMessage);
if (this.commitHistory.length > 10) this.commitHistory.pop();
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
}
} else {
const d = await r.json();
var d = await r.json();
alert('Failed to save: ' + (d.error || 'Unknown error'));
}
} catch(e) {
alert('Network error');
}
} catch(e) { alert('Network error'); }
},
async deleteCurrentFile() {
if (!this.currentFile) return;
if (!confirm(`Delete ${this.currentFile.path} from ${this.owner}/${this.repo}?`)) return;
if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try {
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/file?path=${encodeURIComponent(this.currentFile.path)}&sha=${encodeURIComponent(this.currentFile.sha)}&message=${encodeURIComponent('Delete via FlowDeck')}`, {
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'), {
method: 'DELETE',
});
if (r.ok) {
this.showFile = false;
this.selectedPath = '';
await this.refresh();
this.filePath = '';
await this.refreshTree();
}
} catch(e) {}
},
async refreshTree() {
// Reload page to refresh sidebar tree
window.location.reload();
},
formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1048576).toFixed(1) + ' MB';
},
// ── Private Pages ──
async loadPrivatePages() {
// Pages stored in FlowDeck DB, linked to this Gitea project
// For now, store them as local workspace pages with gitea_owner/gitea_repo
// TODO: Create a dedicated API endpoint for gitea private pages
},
newPrivatePage() {
// TODO: Create a new FlowDeck page linked to this Gitea project
alert('Private pages will be available in a future update. Stay tuned!');
},
openPrivatePage(pp) {
// TODO: Open FlowDeck page editor
},
};
}
</script>
+14 -1
View File
@@ -285,6 +285,17 @@ function workspacesPage() {
// Fetch repos: user repos + org repos
const groups = [];
// Get username for the "personal" tab
let myLogin = 'Me';
try {
const meRes = await fetch('/auth/user');
if (meRes.ok) {
const meData = await meRes.json();
myLogin = meData.user?.login || 'Me';
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
}
} catch(e) {}
// User repos (no org filter)
try {
const userRes = await fetch('/api/gitea/projects');
@@ -292,7 +303,7 @@ function workspacesPage() {
const d = await userRes.json();
const repos = d.projects || [];
if (repos.length) {
groups.push({org: 'Personal', avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: '~'}}))});
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
}
}
} catch(e) {}
@@ -322,6 +333,8 @@ function workspacesPage() {
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
const repo = proj.name;
if (owner && repo) {
// Set workspace cookie so sidebar shows tree
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
}
}