Browse Source

more bug-fixes

main
Silberengel 4 weeks ago
parent
commit
f19d21913a
  1. 1
      repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/aitherboard.work
  2. 30
      src/lib/components/ThemeToggle.svelte
  3. 4
      src/lib/services/git/file-manager.ts
  4. 150
      src/routes/api/repos/[npub]/[repo]/download/+server.ts
  5. 96
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  6. 324
      src/routes/repos/[npub]/[repo]/+page.svelte

1
repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/aitherboard.work

@ -0,0 +1 @@ @@ -0,0 +1 @@
Subproject commit 432ca405d33a6389b1d12529e6e0e76b523b39f8

30
src/lib/components/ThemeToggle.svelte

@ -51,9 +51,23 @@ @@ -51,9 +51,23 @@
<button class="theme-toggle" onclick={handleToggle} title={currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
<span class="theme-toggle-icon">
{#if currentTheme === 'dark'}
<!-- Sun icon for light mode -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
{:else}
🌙
<!-- Moon icon for dark mode -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
{/if}
</span>
</button>
@ -87,7 +101,15 @@ @@ -87,7 +101,15 @@
}
.theme-toggle-icon {
font-size: 1rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.theme-toggle-icon svg {
width: 100%;
height: 100%;
}
</style>

4
src/lib/services/git/file-manager.ts

@ -282,6 +282,10 @@ export class FileManager { @@ -282,6 +282,10 @@ export class FileManager {
* Validate and sanitize file path to prevent path traversal attacks
*/
private validateFilePath(filePath: string): { valid: boolean; error?: string; normalized?: string } {
// Allow empty string for root directory
if (filePath === '') {
return { valid: true, normalized: '' };
}
if (!filePath || typeof filePath !== 'string') {
return { valid: false, error: 'File path must be a non-empty string' };
}

150
src/routes/api/repos/[npub]/[repo]/download/+server.ts

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { fileManager } from '$lib/services/service-registry.js';
import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js';
import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { spawn } from 'child_process';
@ -13,7 +13,10 @@ import { join, resolve } from 'path'; @@ -13,7 +13,10 @@ import { join, resolve } from 'path';
import logger from '$lib/services/logger.js';
import { isValidBranchName, sanitizeError } from '$lib/utils/security.js';
import simpleGit from 'simple-git';
import { handleApiError } from '$lib/utils/error-handler.js';
import { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js';
import { KIND } from '$lib/types/nostr.js';
import { existsSync } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
@ -21,20 +24,108 @@ const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT @@ -21,20 +24,108 @@ const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext, event: RequestEvent) => {
const ref = event.url.searchParams.get('ref') || 'HEAD';
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
// If repo doesn't exist, try to fetch it on-demand
if (!existsSync(repoPath)) {
try {
// Fetch repository announcement from Nostr
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [context.repoOwnerPubkey],
'#d': [context.repo],
limit: 1
}
]);
if (events.length > 0) {
// Try to fetch the repository from remote clone URLs
const fetched = await repoManager.fetchRepoOnDemand(
context.npub,
context.repo,
events[0]
);
// Always check if repo exists after fetch attempt (might have been created)
// Also clear cache to ensure fileManager sees it
if (existsSync(repoPath)) {
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo));
// Repo exists, continue with normal flow
} else if (!fetched) {
// Fetch failed and repo doesn't exist
throw handleNotFoundError(
'Repository not found and could not be fetched from remote. The repository may not have any accessible clone URLs.',
{ operation: 'download', npub: context.npub, repo: context.repo }
);
} else {
// Fetch returned true but repo doesn't exist - this shouldn't happen, but clear cache anyway
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo));
// Wait a moment for filesystem to sync, then check again
await new Promise(resolve => setTimeout(resolve, 100));
if (!existsSync(repoPath)) {
throw handleNotFoundError(
'Repository fetch completed but repository is not accessible',
{ operation: 'download', npub: context.npub, repo: context.repo }
);
}
}
} else {
throw handleNotFoundError(
'Repository announcement not found in Nostr',
{ operation: 'download', npub: context.npub, repo: context.repo }
);
}
} catch (err) {
// Check if repo was created by another concurrent request
if (existsSync(repoPath)) {
// Repo exists now, clear cache and continue with normal flow
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo));
} else {
// If fetching fails, return 404
throw handleNotFoundError(
'Repository not found',
{ operation: 'download', npub: context.npub, repo: context.repo }
);
}
}
}
// Double-check repo exists after on-demand fetch
if (!existsSync(repoPath)) {
throw handleNotFoundError(
'Repository not found',
{ operation: 'download', npub: context.npub, repo: context.repo }
);
}
let ref = event.url.searchParams.get('ref') || 'HEAD';
const format = event.url.searchParams.get('format') || 'zip'; // zip or tar.gz
// Security: Validate ref to prevent command injection
if (ref !== 'HEAD' && !isValidBranchName(ref)) {
throw error(400, 'Invalid ref format');
// If ref is a branch name, validate it exists or use default branch
if (ref !== 'HEAD' && !ref.startsWith('refs/')) {
// Security: Validate ref to prevent command injection
if (!isValidBranchName(ref)) {
throw error(400, 'Invalid ref format');
}
// Validate branch exists or use default
try {
const branches = await fileManager.getBranches(context.npub, context.repo);
if (!branches.includes(ref)) {
// Branch doesn't exist, use default branch
ref = await fileManager.getDefaultBranch(context.npub, context.repo);
}
} catch {
// If we can't get branches, fall back to HEAD
ref = 'HEAD';
}
}
// Security: Validate format
if (format !== 'zip' && format !== 'tar.gz') {
throw error(400, 'Invalid format. Must be "zip" or "tar.gz"');
}
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
// Security: Ensure resolved path is within repoRoot
const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/');
const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/');
@ -77,6 +168,13 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -77,6 +168,13 @@ export const GET: RequestHandler = createRepoGetHandler(
// Remove .git directory using fs/promises
await rm(join(workDir, '.git'), { recursive: true, force: true });
// Verify workDir has content before archiving
const { readdir } = await import('fs/promises');
const workDirContents = await readdir(workDir);
if (workDirContents.length === 0) {
throw new Error('Repository work directory is empty, cannot create archive');
}
// Create archive using spawn (safer than exec)
if (format === 'tar.gz') {
await new Promise<void>((resolve, reject) => {
@ -96,21 +194,45 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -96,21 +194,45 @@ export const GET: RequestHandler = createRepoGetHandler(
});
} else {
// Use zip command (requires zip utility) - using spawn for safety
// Make archive path absolute for zip command
const absoluteArchivePath = resolve(archivePath);
// Ensure the archive directory exists
const archiveDir = join(absoluteArchivePath, '..');
await mkdir(archiveDir, { recursive: true });
await new Promise<void>((resolve, reject) => {
const zipProcess = spawn('zip', ['-r', archivePath, '.'], {
const zipProcess = spawn('zip', ['-r', absoluteArchivePath, '.'], {
cwd: workDir,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
zipProcess.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
zipProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
zipProcess.on('close', (code) => {
zipProcess.on('close', async (code) => {
if (code === 0) {
resolve();
// Verify archive was created
try {
const fs = await import('fs/promises');
await fs.access(absoluteArchivePath);
resolve();
} catch {
reject(new Error(`zip command succeeded but archive file was not created at ${absoluteArchivePath}`));
}
} else {
const errorMsg = (stderr || stdout || 'Unknown error').trim();
reject(new Error(`zip failed with code ${code}: ${errorMsg || 'No error message'}`));
}
});
zipProcess.on('error', (err) => {
// If zip command doesn't exist, provide helpful error
if (err.message.includes('ENOENT') || (err as any).code === 'ENOENT') {
reject(new Error('zip command not found. Please install zip utility (e.g., apt-get install zip or brew install zip)'));
} else {
reject(new Error(`zip failed: ${stderr}`));
reject(err);
}
});
zipProcess.on('error', reject);
});
}
@ -138,5 +260,5 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -138,5 +260,5 @@ export const GET: RequestHandler = createRepoGetHandler(
throw archiveError;
}
},
{ operation: 'download' }
{ operation: 'download', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, downloads are public
);

96
src/routes/api/repos/[npub]/[repo]/file/+server.ts

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
@ -15,15 +15,20 @@ import logger from '$lib/services/logger.js'; @@ -15,15 +15,20 @@ import logger from '$lib/services/logger.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError } from '$lib/utils/error-handler.js';
import { KIND } from '$lib/types/nostr.js';
import { join } from 'path';
import { existsSync } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
: '/repos';
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => {
const { npub, repo } = params;
const filePath = url.searchParams.get('path');
const ref = url.searchParams.get('ref') || 'HEAD';
let ref = url.searchParams.get('ref') || 'HEAD';
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo || !filePath) {
@ -31,11 +36,75 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { @@ -31,11 +36,75 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
}
try {
if (!fileManager.repoExists(npub, repo)) {
const repoPath = join(repoRoot, npub, `${repo}.git`);
// If repo doesn't exist, try to fetch it on-demand
if (!existsSync(repoPath)) {
try {
// Get repo owner pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return error(400, 'Invalid npub format');
}
// Fetch repository announcement from Nostr
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [repoOwnerPubkey],
'#d': [repo],
limit: 1
}
]);
if (events.length > 0) {
// Try to fetch the repository from remote clone URLs
const fetched = await repoManager.fetchRepoOnDemand(
npub,
repo,
events[0]
);
// Always check if repo exists after fetch attempt (might have been created)
// Also clear cache to ensure fileManager sees it
if (existsSync(repoPath)) {
repoCache.delete(RepoCache.repoExistsKey(npub, repo));
// Repo exists, continue with normal flow
} else if (!fetched) {
// Fetch failed and repo doesn't exist
return error(404, 'Repository not found and could not be fetched from remote. The repository may not have any accessible clone URLs.');
} else {
// Fetch returned true but repo doesn't exist - this shouldn't happen, but clear cache anyway
repoCache.delete(RepoCache.repoExistsKey(npub, repo));
// Wait a moment for filesystem to sync, then check again
await new Promise(resolve => setTimeout(resolve, 100));
if (!existsSync(repoPath)) {
return error(404, 'Repository fetch completed but repository is not accessible');
}
}
} else {
return error(404, 'Repository announcement not found in Nostr');
}
} catch (err) {
// Check if repo was created by another concurrent request
if (existsSync(repoPath)) {
// Repo exists now, clear cache and continue with normal flow
repoCache.delete(RepoCache.repoExistsKey(npub, repo));
} else {
// If fetching fails, return 404
return error(404, 'Repository not found');
}
}
}
// Double-check repo exists after on-demand fetch
if (!existsSync(repoPath)) {
return error(404, 'Repository not found');
}
// Check repository privacy
// Get repo owner pubkey for access check (already validated above if we did on-demand fetch)
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
@ -43,6 +112,21 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { @@ -43,6 +112,21 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
return error(400, 'Invalid npub format');
}
// If ref is a branch name, validate it exists or use default branch
if (ref !== 'HEAD' && !ref.startsWith('refs/')) {
try {
const branches = await fileManager.getBranches(npub, repo);
if (!branches.includes(ref)) {
// Branch doesn't exist, use default branch
ref = await fileManager.getDefaultBranch(npub, repo);
}
} catch {
// If we can't get branches, fall back to HEAD
ref = 'HEAD';
}
}
// Check repository privacy (repoOwnerPubkey already declared above)
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) {
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';

324
src/routes/repos/[npub]/[repo]/+page.svelte

@ -126,6 +126,9 @@ @@ -126,6 +126,9 @@
let repoImage = $state<string | null>(null);
let repoBanner = $state<string | null>(null);
// Mobile view toggle for file list/file viewer
let showFileListOnMobile = $state(true);
async function loadReadme() {
if (repoNotFound) return;
loadingReadme = true;
@ -212,6 +215,12 @@ @@ -212,6 +215,12 @@
'conf': 'ini',
'log': 'plaintext',
'txt': 'plaintext',
'md': 'markdown',
'markdown': 'markdown',
'mdown': 'markdown',
'mkdn': 'markdown',
'mkd': 'markdown',
'mdwn': 'markdown',
'adoc': 'asciidoc',
'asciidoc': 'asciidoc',
'ad': 'asciidoc',
@ -226,6 +235,96 @@ @@ -226,6 +235,96 @@
const hljs = hljsModule.default || hljsModule;
const lang = getHighlightLanguage(ext);
// Register Markdown language if needed (not in highlight.js by default)
if (lang === 'markdown' && !hljs.getLanguage('markdown')) {
hljs.registerLanguage('markdown', function(hljs) {
return {
name: 'Markdown',
aliases: ['md', 'mkdown', 'mkd'],
contains: [
// Headers
{
className: 'section',
begin: /^#{1,6}\s+/,
relevance: 10
},
// Bold
{
className: 'strong',
begin: /\*\*[^*]+\*\*/,
relevance: 0
},
{
className: 'strong',
begin: /__[^_]+__/,
relevance: 0
},
// Italic
{
className: 'emphasis',
begin: /\*[^*]+\*/,
relevance: 0
},
{
className: 'emphasis',
begin: /_[^_]+_/,
relevance: 0
},
// Inline code
{
className: 'code',
begin: /`[^`]+`/,
relevance: 0
},
// Code blocks
{
className: 'code',
begin: /^```[\w]*/,
end: /^```$/,
contains: [{ begin: /./ }]
},
// Links
{
className: 'link',
begin: /\[/,
end: /\]/,
contains: [
{
className: 'string',
begin: /\(/,
end: /\)/
}
]
},
// Images
{
className: 'string',
begin: /!\[/,
end: /\]/
},
// Lists
{
className: 'bullet',
begin: /^(\s*)([*+-]|\d+\.)\s+/,
relevance: 0
},
// Blockquotes
{
className: 'quote',
begin: /^>\s+/,
relevance: 0
},
// Horizontal rules
{
className: 'horizontal_rule',
begin: /^(\*{3,}|-{3,}|_{3,})$/,
relevance: 0
}
]
};
});
}
// Register AsciiDoc language if needed (not in highlight.js by default)
if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) {
hljs.registerLanguage('asciidoc', function(hljs) {
@ -692,6 +791,10 @@ @@ -692,6 +791,10 @@
loadFiles(file.path);
} else {
loadFile(file.path);
// On mobile, switch to file viewer when a file is clicked
if (window.innerWidth <= 768) {
showFileListOnMobile = false;
}
}
}
@ -1184,10 +1287,10 @@ @@ -1184,10 +1287,10 @@
<meta property="og:title" content={pageData.title || `${pageData.repoName || repo} - Repository`} />
<meta property="og:description" content={pageData.description || pageData.repoDescription || `Repository: ${pageData.repoName || repo}`} />
<meta property="og:url" content={pageData.repoUrl || `https://${$page.url.host}${$page.url.pathname}`} />
{#if pageData.image || repoImage}
{#if (pageData.image || repoImage) && String(pageData.image || repoImage).trim()}
<meta property="og:image" content={pageData.image || repoImage} />
{/if}
{#if pageData.banner || repoBanner}
{#if (pageData.banner || repoBanner) && String(pageData.banner || repoBanner).trim()}
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
{/if}
@ -1390,14 +1493,14 @@ @@ -1390,14 +1493,14 @@
class:active={activeTab === 'docs'}
onclick={() => activeTab = 'docs'}
>
Documentation
Docs
</button>
</div>
<div class="repo-layout">
<!-- File Tree Sidebar -->
{#if activeTab === 'files'}
<aside class="file-tree">
<aside class="file-tree" class:hide-on-mobile={!showFileListOnMobile && activeTab === 'files'}>
<div class="file-tree-header">
<h2>Files</h2>
<div class="file-tree-actions">
@ -1407,6 +1510,17 @@ @@ -1407,6 +1510,17 @@
{#if userPubkey && isMaintainer}
<button onclick={() => showCreateFileDialog = true} class="create-file-button">+ New File</button>
{/if}
<button
onclick={() => showFileListOnMobile = !showFileListOnMobile}
class="mobile-toggle-button"
title={showFileListOnMobile ? 'Show file viewer' : 'Show file list'}
>
{#if showFileListOnMobile}
<img src="/icons/file-text.svg" alt="Show file viewer" class="icon-inline" />
{:else}
<img src="/icons/package.svg" alt="Show file list" class="icon-inline" />
{/if}
</button>
</div>
</div>
{#if loading && !currentFile}
@ -1567,7 +1681,7 @@ @@ -1567,7 +1681,7 @@
{/if}
<!-- Editor Area / Diff View / README -->
<div class="editor-area">
<div class="editor-area" class:hide-on-mobile={showFileListOnMobile && activeTab === 'files'}>
{#if activeTab === 'files' && readmeContent && !currentFile}
<div class="readme-section">
<div class="readme-header">
@ -1575,6 +1689,17 @@ @@ -1575,6 +1689,17 @@
<div class="readme-actions">
<a href={`/api/repos/${npub}/${repo}/raw?path=${readmePath}`} target="_blank" class="raw-link">View Raw</a>
<a href={`/api/repos/${npub}/${repo}/download?format=zip`} class="download-link">Download ZIP</a>
<button
onclick={() => showFileListOnMobile = !showFileListOnMobile}
class="mobile-toggle-button"
title={showFileListOnMobile ? 'Show file viewer' : 'Show file list'}
>
{#if showFileListOnMobile}
<img src="/icons/file-text.svg" alt="Show file viewer" class="icon-inline" />
{:else}
<img src="/icons/package.svg" alt="Show file list" class="icon-inline" />
{/if}
</button>
</div>
</div>
{#if loadingReadme}
@ -1605,6 +1730,17 @@ @@ -1605,6 +1730,17 @@
{:else if userPubkey}
<span class="non-maintainer-notice">Only maintainers can edit files. Submit a PR instead.</span>
{/if}
<button
onclick={() => showFileListOnMobile = !showFileListOnMobile}
class="mobile-toggle-button"
title={showFileListOnMobile ? 'Show file viewer' : 'Show file list'}
>
{#if showFileListOnMobile}
<img src="/icons/file-text.svg" alt="Show file viewer" class="icon-inline" />
{:else}
<img src="/icons/package.svg" alt="Show file list" class="icon-inline" />
{/if}
</button>
</div>
</div>
@ -2124,6 +2260,31 @@ @@ -2124,6 +2260,31 @@
}
/* Responsive design for smaller screens */
.mobile-toggle-button {
display: none; /* Hidden by default on desktop */
padding: 0.5rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
align-items: center;
justify-content: center;
}
.mobile-toggle-button .icon-inline {
width: 16px;
height: 16px;
}
.mobile-toggle-button:hover {
background: var(--bg-primary);
}
.hide-on-mobile {
display: none;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
@ -2145,6 +2306,47 @@ @@ -2145,6 +2306,47 @@
margin-top: -30px;
}
.repo-image {
width: 60px;
height: 60px;
}
/* Mobile toggle button visible on narrow screens */
.mobile-toggle-button {
display: inline-flex;
}
/* File tree and editor area full width and height on mobile */
.file-tree {
width: 100%;
flex: 1 1 auto;
min-height: 0;
flex-basis: auto;
}
.editor-area {
width: 100%;
flex: 1;
min-height: 0;
max-height: none;
}
/* Hide the appropriate view based on toggle state */
.file-tree.hide-on-mobile {
display: none !important;
}
.editor-area.hide-on-mobile {
display: none !important;
}
/* Stack layout on mobile */
.repo-layout {
flex-direction: column;
flex: 1;
min-height: 0;
}
.repo-image {
width: 64px;
height: 64px;
@ -2153,6 +2355,64 @@ @@ -2153,6 +2355,64 @@
.repo-title-text h1 {
font-size: 1.5rem;
}
/* Editor header wraps on mobile */
.editor-header {
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
align-items: flex-start;
}
.file-path {
flex: 1 1 100%;
min-width: 0;
word-break: break-all;
margin-bottom: 0;
padding-bottom: 0;
}
.editor-actions {
flex: 1 1 auto;
justify-content: flex-end;
min-width: 0;
gap: 0.5rem;
}
.non-maintainer-notice {
font-size: 0.7rem;
flex: 1 1 100%;
order: 2;
margin-top: 0;
padding-top: 0.25rem;
line-height: 1.3;
}
/* Make tabs more compact on mobile */
.tabs {
padding: 0.4rem 0.5rem;
gap: 0.2rem;
}
.tab-button {
padding: 0.35rem 0.6rem;
font-size: 0.75rem;
}
}
/* Desktop: always show both file tree and editor */
@media (min-width: 769px) {
.file-tree.hide-on-mobile {
display: flex;
}
.editor-area.hide-on-mobile {
display: flex;
}
.mobile-toggle-button {
display: none;
}
}
.repo-image[src=""],
@ -2424,13 +2684,15 @@ @@ -2424,13 +2684,15 @@
.file-tree {
width: 300px;
min-width: 300px;
max-width: 300px;
border-right: 1px solid var(--border-color);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%; /* Ensure full height */
max-height: calc(100vh - 200px); /* Constrain to viewport with some margin */
flex: 0 0 300px; /* Fixed width, don't grow or shrink */
min-height: 0; /* Allow flex child to shrink */
}
.file-tree-header {
@ -2470,7 +2732,7 @@ @@ -2470,7 +2732,7 @@
overflow-x: hidden;
flex: 1;
min-height: 0; /* Allows flex child to shrink below content size */
max-height: 100%; /* Constrains height for scrolling */
width: 100%; /* Fill horizontal space */
}
.file-item {
@ -2514,6 +2776,7 @@ @@ -2514,6 +2776,7 @@
flex-direction: column;
overflow: hidden;
background: var(--card-bg);
max-height: calc(200vh - 400px); /* Twice the original height */
}
.editor-header {
@ -2534,6 +2797,7 @@ @@ -2534,6 +2797,7 @@
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.unsaved-indicator {
@ -2541,6 +2805,13 @@ @@ -2541,6 +2805,13 @@
font-size: 0.875rem;
}
.non-maintainer-notice {
font-size: 0.75rem;
color: var(--text-muted);
white-space: normal;
line-height: 1.4;
}
.save-button {
padding: 0.5rem 1rem;
background: var(--button-primary);
@ -2566,6 +2837,9 @@ @@ -2566,6 +2837,9 @@
.editor-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0; /* Allows flex child to shrink below content size */
}
.empty-state {
@ -2677,22 +2951,41 @@ @@ -2677,22 +2951,41 @@
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
padding: 0.5rem 2rem;
gap: 0.25rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--card-bg);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar {
height: 4px;
}
.tabs::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
.tabs::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
.tab-button {
padding: 0.5rem 1rem;
padding: 0.4rem 0.75rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
font-size: 0.8rem;
color: var(--text-muted);
font-family: 'IBM Plex Serif', serif;
transition: color 0.2s ease, border-color 0.2s ease;
white-space: nowrap;
flex-shrink: 0;
}
.tab-button:hover {
@ -2947,7 +3240,10 @@ @@ -2947,7 +3240,10 @@
.read-only-editor {
height: 100%;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
padding: 1.5rem;
min-height: 0; /* Allows flex child to shrink below content size */
}
.read-only-editor :global(.hljs) {
@ -2969,6 +3265,8 @@ @@ -2969,6 +3265,8 @@
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
line-height: 1.5;
display: block;
white-space: pre;
}
.readme-section {

Loading…
Cancel
Save