Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions apps/web/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App';
import { authStore } from './stores/auth.store';
import { fakeCoreKitSession, fakeEngineClient, pageWrapper } from './test/authFakes';

function renderAt(path: string) {
Expand All @@ -15,20 +16,32 @@ function renderAt(path: string) {
);
}

afterEach(() => authStore.signedOut());

describe('App routes', () => {
it('renders the login page at the root', () => {
renderAt('/');
expect(screen.getByRole('heading', { name: 'CipherBox' })).toBeDefined();
});

it('renders the vault browser for the vault root when no node id is routed', () => {
authStore.signedIn('google', 'user@example.test');
renderAt('/files');
expect(screen.getByTestId('files-node').textContent).toBe('root');
expect(screen.getByTestId('app-shell')).toBeDefined();
expect(screen.getByTestId('file-browser')).toBeDefined();
});

it('keys the vault browser on the routed node id', () => {
renderAt('/files/0a1b2c');
expect(screen.getByTestId('files-node').textContent).toBe('0a1b2c');
authStore.signedIn('google', 'user@example.test');
renderAt(`/files/${'0a'.repeat(16)}`);
expect(screen.getByTestId('file-browser')).toBeDefined();
});

it('sends a signed-out tab away from the vault browser', async () => {
renderAt('/files');
// The redirect waits on the Core Kit restore: a tab that is still deciding
// must not be thrown out of its own vault.
expect(await screen.findByRole('heading', { name: 'CipherBox' })).toBeDefined();
});

it('sends an unknown path back to login', () => {
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/components/file-browser/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Fragment } from 'react';
import type { BreadcrumbDescriptor } from '@cipherbox/client';
import { toHex } from '@cipherbox/client';

interface BreadcrumbsProps {
/** Root-first trail, ending at the folder on screen. */
crumbs: BreadcrumbDescriptor[];
onNavigate: (node: Uint8Array) => void;
}

/** The vault's current location as a terminal path: `~/root/documents`. */
export function Breadcrumbs({ crumbs, onNavigate }: BreadcrumbsProps) {
return (
<nav className="breadcrumb-nav" aria-label="Current location" data-testid="breadcrumbs">
<span className="breadcrumb-prefix">~</span>
{crumbs.map((crumb, index) => {
const isCurrent = index === crumbs.length - 1;
return (
<Fragment key={toHex(crumb.id)}>
<span className="breadcrumb-separator">/</span>
<button
type="button"
className={isCurrent ? 'breadcrumb-item breadcrumb-item--current' : 'breadcrumb-item'}
onClick={() => onNavigate(crumb.id)}
aria-current={isCurrent ? 'page' : undefined}
>
{/* The vault root carries no name of its own. */}
{crumb.name === '' ? 'root' : crumb.name}
</button>
</Fragment>
);
})}
</nav>
);
}
20 changes: 20 additions & 0 deletions apps/web/src/components/file-browser/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** Terminal window running `ls -la` over nothing, in box-drawing characters. */
const TERMINAL_ART = `┌──────────────────────┐
│ $ ls -la │
│ total 0 │
│ $ █ │
└──────────────────────┘`;

/** What a folder with no children shows. */
export function EmptyState() {
return (
<div className="empty-state" data-testid="empty-state">
<div className="empty-state-content">
<pre className="empty-state-ascii" aria-hidden="true">
{TERMINAL_ART}
</pre>
<p className="empty-state-text">// EMPTY DIRECTORY</p>
</div>
</div>
);
}
37 changes: 37 additions & 0 deletions apps/web/src/components/file-browser/FileBrowser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useFolderNavigation } from '../../vault/useFolderNavigation';
import { Breadcrumbs } from './Breadcrumbs';
import { EmptyState } from './EmptyState';
import { FileList } from './FileList';

/** The vault browser: where you are, what is in it, and how to move. */
export function FileBrowser() {
const { rows, breadcrumbs, isLoading, isRoot, error, navigateTo, navigateUp } =
useFolderNavigation();
const settled = !isLoading && error === null;

return (
<div className="file-browser" data-testid="file-browser">
<Breadcrumbs crumbs={breadcrumbs} onNavigate={navigateTo} />
{error && (
<p className="file-browser-error" role="alert" data-testid="file-browser-error">
{error.message}
</p>
)}
{isLoading && (
<p className="file-browser-loading" data-testid="file-browser-loading">
{'// LOADING VAULT...'}
</p>
)}
{/* An empty non-root folder still lists, so `[..]` remains reachable. */}
{settled && (rows.length > 0 || !isRoot) && (
<FileList
rows={rows}
showParentRow={!isRoot}
onOpen={navigateTo}
onNavigateUp={navigateUp}
/>
)}
{settled && rows.length === 0 && <EmptyState />}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
);
}
36 changes: 36 additions & 0 deletions apps/web/src/components/file-browser/FileList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ListingRow } from '../../vault/listing';
import { FileListItem } from './FileListItem';
import { ParentDirRow } from './ParentDirRow';

interface FileListProps {
rows: ListingRow[];
/** False at the vault root, which has no parent to step up to. */
showParentRow: boolean;
onOpen: (node: Uint8Array) => void;
onNavigateUp: () => void;
}

/** The routed folder's direct children, in columns. */
export function FileList({ rows, showParentRow, onOpen, onNavigateUp }: FileListProps) {
return (
<div className="file-list" role="grid" data-testid="file-list">
<div className="file-list-header" role="row">
<div className="file-list-header-name" role="columnheader">
[NAME]
</div>
<div className="file-list-header-size" role="columnheader">
[SIZE]
</div>
<div className="file-list-header-date" role="columnheader">
[MODIFIED]
</div>
</div>
<div className="file-list-body" role="rowgroup">
{showParentRow && <ParentDirRow onActivate={onNavigateUp} />}
{rows.map((row) => (
<FileListItem key={row.key} row={row} onOpen={onOpen} />
))}
</div>
</div>
);
}
64 changes: 64 additions & 0 deletions apps/web/src/components/file-browser/FileListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ListingRow } from '../../vault/listing';

interface FileListItemProps {
row: ListingRow;
/** Opens a folder. Files have no read action until #808 lands. */
onOpen: (node: Uint8Array) => void;
}

/** One direct child: kind marker, name, size, mtime, and its queue status. */
export function FileListItem({ row, onOpen }: FileListItemProps) {
const isFolder = row.kind === 'folder';
const open = () => {
if (isFolder) onOpen(row.id);
};

return (
<div
className="file-list-item"
data-node-id={row.key}
data-testid="file-list-item"
role="row"
tabIndex={0}
onDoubleClick={open}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
open();
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
<div className="file-list-item-row-top" role="gridcell">
<span className="file-list-item-icon" aria-hidden="true">
{row.icon}
</span>
<span className="file-list-item-name">{row.name}</span>
<ItemStatus row={row} />
</div>
<div className="file-list-item-row-bottom">
<span className="file-list-item-size" role="gridcell">
{row.size}
</span>
<span className="file-list-item-date" role="gridcell">
{row.modified}
</span>
</div>
</div>
);
}

/** The engine's per-node queue flags, rendered as the engine reports them. */
function ItemStatus({ row }: { row: ListingRow }) {
if (row.deadLetter) {
return (
<span className="file-list-item-status file-list-item-status--dead" title="will not publish">
[!]
</span>
);
}
if (row.pending === 'none') return null;
return (
<span className="file-list-item-status" title={`${row.pending} change not published yet`}>
[~]
</span>
);
}
36 changes: 36 additions & 0 deletions apps/web/src/components/file-browser/ParentDirRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
interface ParentDirRowProps {
onActivate: () => void;
}

/** The `[..]` row that opens the parent folder, first in every non-root list. */
export function ParentDirRow({ onActivate }: ParentDirRowProps) {
return (
<div
className="file-list-item file-list-item--parent"
role="row"
tabIndex={0}
onDoubleClick={onActivate}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onActivate();
}}
data-testid="parent-dir-row"
>
<div className="file-list-item-row-top" role="gridcell">
<span className="file-list-item-icon" aria-hidden="true">
[..]
</span>
<span className="file-list-item-name">PARENT_DIR</span>
</div>
<div className="file-list-item-row-bottom">
<span className="file-list-item-size" role="gridcell">
-
</span>
<span className="file-list-item-date" role="gridcell">
-
</span>
</div>
</div>
);
}
25 changes: 25 additions & 0 deletions apps/web/src/components/layout/AppFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { StatusIndicator } from './StatusIndicator';

/** Chrome: attribution, outbound links, and the staleness rung. */
export function AppFooter() {
return (
<footer className="app-footer" data-testid="app-footer">
<div className="footer-left">
<span className="footer-copyright">(c) 2026 CipherBox</span>
</div>
<div className="footer-center">
<a
href="https://github.com/fsm1/cipher-box"
className="footer-link"
target="_blank"
rel="noopener noreferrer"
>
[github]
</a>
</div>
<div className="footer-right">
<StatusIndicator />
</div>
</footer>
);
}
16 changes: 16 additions & 0 deletions apps/web/src/components/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { UserMenu } from './UserMenu';

/** Wordmark and account menu. */
export function AppHeader() {
return (
<header className="app-header" data-testid="app-header">
<div className="header-left">
<span className="header-prompt">&gt;</span>
<span className="header-logo">CIPHERBOX</span>
</div>
<div className="header-right">
<UserMenu />
</div>
</header>
);
}
24 changes: 24 additions & 0 deletions apps/web/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react';
import { StagingBanner } from '../StagingBanner';
import { AppFooter } from './AppFooter';
import { AppHeader } from './AppHeader';
import { AppSidebar } from './AppSidebar';

interface AppShellProps {
children: ReactNode;
}

/** The signed-in frame: header, sidebar, scrollable main, footer. */
export function AppShell({ children }: AppShellProps) {
return (
<div className="app-frame">
<StagingBanner />
<div className="app-shell" data-testid="app-shell">
<AppHeader />
<AppSidebar />
<main className="app-main">{children}</main>
<AppFooter />
</div>
</div>
);
}
18 changes: 18 additions & 0 deletions apps/web/src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useLocation } from 'react-router-dom';
import { NavItem } from './NavItem';

/** Vault navigation. Only `/files` is served in this build; the rest is #643. */
export function AppSidebar() {
const { pathname } = useLocation();

return (
<aside className="app-sidebar" data-testid="app-sidebar">
<nav className="sidebar-nav">
<NavItem to="/files" icon="folder" label="Files" active={pathname.startsWith('/files')} />
<NavItem to="/shared" icon="shared" label="Shared" active={false} comingSoon />
<NavItem to="/bin" icon="bin" label="Bin" active={false} comingSoon />
<NavItem to="/settings" icon="settings" label="Settings" active={false} comingSoon />
</nav>
</aside>
);
}
Loading