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
81 changes: 75 additions & 6 deletions apps/desktop/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
--color-info-surface: #12244a;
--color-info-line: #2f5fd0;
--color-info-ink: #9dc0ff;

}

/*
Expand All @@ -60,6 +61,25 @@
color-scheme: dark;
--radius: 0.625rem;

/*
* The control scale. Every toolbar button, field and rail action picks a
* height from these rather than inventing one -- which is what left the rail
* stacking a 64px button on a 50px button with two different corner radii.
*/
--control-sm: 26px;
--control: 30px;
--control-lg: 34px;
--row: 32px;

/*
* Elevation. A panel used to be a 1px border and nothing else, which on the
* light theme meant a white card on a white window: the panes had no
* presence at all. Depth is a token so both themes get a considered version
* rather than a shadow someone guessed at inline.
*/
--shadow-panel: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px -14px rgb(0 0 0 / 0.6);
--shadow-armed: 0 1px 0 rgb(255 255 255 / 0.1) inset, 0 6px 18px -6px rgb(11 98 253 / 0.55);

--background: #070c16;
--foreground: #e8edf6;
--card: #0e1522;
Expand Down Expand Up @@ -91,7 +111,13 @@
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--background: #ffffff;
/*
* The window ground is deliberately NOT white. It used to be, and so did
* the panes sitting on it, so a pane was a hairline rectangle around
* nothing and the whole app read as an unstyled document. The panes keep
* white; the ground steps back so they read as panels.
*/
--background: #eef1f6;
--foreground: #001830;
--card: #ffffff;
--card-foreground: #001830;
Expand All @@ -110,16 +136,26 @@
--input: #dfe4ed;
--ring: #0b62fd;

--color-ink: #ffffff;
--color-chrome: #f7f9fc;
/*
* Chrome sits a step off card in BOTH themes, so a pane header and a pane
* footer read as chrome rather than as more list. Light had them both at
* pure white, which left only the hairline border to say where the toolbar
* ended -- the same mistake as the ground being white.
*/
--color-ink: #eef1f6;
--color-chrome: #f8fafc;
--color-surface: #ffffff;
--color-raised: #f2f5fa;
--color-sunken: #f7f9fc;
--color-line: #dfe4ed;
--color-raised: #f4f7fb;
--color-sunken: #f4f7fa;
--color-line: #e2e7ef;
--color-line-strong: #cbd4e2;
--color-dim: #35435c;
--color-faint: #7a869c;

/* Depth is subtler on white than it can afford to be on near-black. */
--shadow-panel: 0 1px 2px rgb(16 32 64 / 0.06), 0 6px 20px -12px rgb(16 32 64 / 0.28);
--shadow-armed: 0 1px 0 rgb(255 255 255 / 0.25) inset, 0 6px 16px -6px rgb(11 98 253 / 0.45);

/* tints, not near-blacks, so they read as a highlight on a white window */
--color-danger-surface: #fef2f2;
--color-danger-line: #fbcfd3;
Expand Down Expand Up @@ -178,6 +214,39 @@ body {
user-select: text;
}

/* Elevation as a class rather than a Tailwind shadow utility, so the light
theme's override of --shadow-panel actually reaches it. */
.elevation-panel {
box-shadow: var(--shadow-panel);
}
.elevation-armed {
box-shadow: var(--shadow-armed);
}

/*
* Sizes, dates and counts are compared down a column, so their glyphs have to
* occupy the same width. Without this the mono column still jitters, because
* IBM Plex Mono's proportional fallback wins for any digit it does not cover.
*/
.numeric {
font-family: var(--font-mono), ui-monospace, monospace;
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}

/*
* One focus ring for the whole app. shadcn's own controls already draw this;
* the hand-rolled toolbar buttons did not, so tabbing through a pane went
* invisible halfway across.
*/
.focus-ring {
outline: none;
}
.focus-ring:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}

/* A scrollbar that does not shout inside a dark pane. */
*::-webkit-scrollbar {
width: 10px;
Expand Down
19 changes: 13 additions & 6 deletions apps/desktop/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { Metadata } from 'next'
import { IBM_Plex_Mono, IBM_Plex_Sans, Geist } from 'next/font/google'
import { IBM_Plex_Mono, Geist } from 'next/font/google'
import './globals.css'
import { cn } from "@/lib/utils";
import Script from "next/script";
import { cn } from '@/lib/utils'
import { TooltipProvider } from '@/components/ui/tooltip'

/**
* Self-hosted at build time by next/font, which matters here: the renderer
* runs under `font-src 'self' data:`, so a stylesheet from Google would be
* blocked and the app would silently fall back to a system face.
*/
const geist = Geist({subsets:['latin'],variable:'--font-sans'})
const geist = Geist({ subsets: ['latin'], variable: '--font-sans', display: 'swap' })

const mono = IBM_Plex_Mono({
subsets: ['latin'],
Expand All @@ -25,8 +25,15 @@ export const metadata: Metadata = {

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={cn(mono.variable, "font-sans", geist.variable)}>
<body className="h-full font-[family-name:var(--font-sans)]">{children} <Script data-site="4f356371-83a0-4cb2-b570-75b961e6ddcb" src="https://crawlproof.com/stats.js" strategy="afterInteractive" />
<html lang="en" className={cn(mono.variable, geist.variable, 'font-sans')}>
{/*
No third-party analytics tag here. One used to load stats.js from a
remote origin, which the shell's own CSP (`script-src 'self'`) blocks
outright -- so it never reported anything, and all it actually did was
make a desktop file transfer tool look like it phones home.
*/}
<body className="h-full font-[family-name:var(--font-sans)] antialiased">
<TooltipProvider delay={350}>{children}</TooltipProvider>
</body>
</html>
)
Expand Down
150 changes: 127 additions & 23 deletions apps/desktop/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,40 @@

import { useCallback, useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { CircleCheck, Settings, Users } from 'lucide-react'
import {
CircleAlert,
CircleCheck,
ExternalLink,
FileDown,
MonitorOff,
Plus,
Settings,
Users,
X,
} from 'lucide-react'
import { ConnectionDialog } from '@/components/connection-dialog'
import { endpointLabel, loadPane, Pane, type PaneEndpoint, type PaneState } from '@/components/pane'
import { TransferRail } from '@/components/transfer-rail'
import { MirrorPreviewDialog, TransferBand, type ActiveJob } from '@/components/transfer-panel'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { api, unwrap, type Connection, type PreviewResult, type TransferEvent } from '@/lib/api'
import { withTrailingSlash } from '@/lib/format'

/** A row in the header menu. Plain button, styled once. */
function MenuItem({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="focus-ring flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-[12.5px] text-dim transition-colors hover:bg-secondary hover:text-foreground"
>
<span className="text-faint">{icon}</span>
{label}
</button>
)
}

const blankPane = (endpoint: PaneEndpoint, path: string): PaneState => ({
endpoint,
path,
Expand Down Expand Up @@ -46,6 +71,17 @@ export default function Workspace() {
}
}, [])

const importSshConfig = useCallback(async () => {
setError(null)
try {
const imported = await unwrap(api()?.connections.importSshConfig())
if (imported.length === 0) setError('No importable hosts found in ~/.ssh/config.')
else await refreshConnections()
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught))
}
}, [refreshConnections])

useEffect(() => {
if (!api()) {
setOutsideShell(true)
Expand Down Expand Up @@ -176,10 +212,16 @@ export default function Workspace() {
if (outsideShell) {
return (
<div className="flex h-full items-center justify-center p-8">
<p className="max-w-md rounded-lg border border-line bg-card p-4 text-[13px] text-muted-foreground">
This is the DiskPush renderer. It runs inside the desktop shell, which provides the filesystem and transfer
bridge.
</p>
<div className="elevation-panel max-w-md rounded-xl border border-line bg-card p-5 text-center">
<span className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-secondary text-faint">
<MonitorOff className="size-[18px]" />
</span>
<p className="text-[13px] font-medium text-dim">Not running in the desktop shell</p>
<p className="mt-1 text-[12px] leading-relaxed text-muted-foreground">
This is the DiskPush renderer. It needs the desktop shell, which provides the filesystem and transfer
bridge.
</p>
</div>
</div>
)
}
Expand All @@ -194,7 +236,7 @@ export default function Workspace() {

return (
<div className="flex h-full flex-col">
<header className="flex h-[52px] shrink-0 items-center gap-4 border-b border-line bg-chrome px-4">
<header className="flex h-[52px] shrink-0 items-center gap-3.5 border-b border-line bg-chrome px-4">
{/*
Two files, not one. logo.dark.png is the lockup FOR a dark background:
its "Disk" is white, so on the light theme it vanished and the header
Expand All @@ -218,24 +260,84 @@ export default function Workspace() {
priority
/>
<div className="h-5 w-px bg-line" />
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<CircleCheck className="size-3.5 text-ok" />
<span className="text-foreground">{connected}</span>
<span>server{connected === 1 ? '' : 's'} available</span>
<div className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
{connected > 0 ? (
<CircleCheck className="size-3.5 text-ok" />
) : (
<CircleAlert className="size-3.5 text-warn" />
)}
{connected > 0 ? (
<>
<span className="numeric font-medium text-foreground">{connected}</span>
<span>server{connected === 1 ? '' : 's'} available</span>
</>
) : (
<span>No servers yet</span>
)}
</div>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" onClick={() => setShowConnection(true)} className="h-[30px] gap-2 border-line-strong text-[12px]">
<Button
variant="outline"
onClick={() => setShowConnection(true)}
className="h-[var(--control)] gap-2 border-line-strong text-[12px]"
>
<Users className="size-3.5" />
New server
</Button>
<Button variant="outline" className="h-[30px] w-[30px] border-line-strong p-0 text-muted-foreground">
<Settings className="size-[15px]" />
</Button>

{/*
This gear had no onClick at all -- a control in the top-right corner
of the window that did nothing when pressed, which on its own is
enough to make an app feel half-built. It now opens the two actions
that already exist behind the bridge, plus the project link.
*/}
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
aria-label="Menu"
className="size-[var(--control)] border-line-strong p-0 text-muted-foreground"
/>
}
>
<Settings className="size-[15px]" />
</PopoverTrigger>
<PopoverContent align="end" className="w-[236px] gap-0.5 p-1.5">
<MenuItem
icon={<Plus className="size-3.5" />}
onClick={() => setShowConnection(true)}
label="Add a server…"
/>
<MenuItem
icon={<FileDown className="size-3.5" />}
onClick={() => void importSshConfig()}
label="Import from ~/.ssh/config"
/>
<div className="my-1 h-px bg-line" />
<MenuItem
icon={<ExternalLink className="size-3.5" />}
onClick={() => void api()?.shell.openExternal('https://diskpush.com')}
label="diskpush.com"
/>
</PopoverContent>
</Popover>
</div>
</header>

{error ? (
<div className="selectable border-b border-danger-line bg-danger-surface px-4 py-2 text-[12px] text-danger-ink">{error}</div>
<div className="flex shrink-0 items-start gap-2.5 border-b border-danger-line bg-danger-surface px-4 py-2.5 text-[12px] text-danger-ink">
<CircleAlert className="mt-px size-3.5 shrink-0 text-destructive" />
<span className="selectable min-w-0 flex-1">{error}</span>
<button
type="button"
aria-label="Dismiss"
onClick={() => setError(null)}
className="focus-ring -mr-1 shrink-0 rounded p-0.5 text-danger-ink/70 transition-colors hover:text-danger-ink"
>
<X className="size-3.5" />
</button>
</div>
) : null}

<div className="flex min-h-0 flex-1 gap-0 p-3.5">
Expand Down Expand Up @@ -281,6 +383,7 @@ export default function Workspace() {
<TransferBand
job={job}
route={route}
mirror={mirror}
onCancel={() => {
if (job) void api()?.transfers.cancel(job.jobId)
}}
Expand All @@ -292,20 +395,21 @@ export default function Workspace() {
deletes. A command line nobody can trust is worse than none, so it is
built from the same state the transfer is.
*/}
<footer className="flex h-[30px] shrink-0 items-center gap-3 border-t border-line bg-background px-4 text-[11px] text-faint">
<footer className="flex h-[28px] shrink-0 items-center gap-2.5 border-t border-line bg-background px-4 text-[11px] text-faint">
<span>Incremental</span>
<span className="text-line-strong">·</span>
<span>Archive metadata</span>
<span className="text-line-strong">·</span>
<span>Resume</span>
<span className="text-line-strong">·</span>
<span className={mirror ? 'font-medium text-destructive' : 'text-ok'}>
Deletes {mirror ? 'ON' : 'off'}
</span>
<span
className="selectable ml-auto max-w-[52%] truncate font-[family-name:var(--font-mono)] text-[10.5px]"
title={rsyncFlags}
>
<span className={mirror ? 'font-medium text-destructive' : 'text-ok'}>Deletes {mirror ? 'ON' : 'off'}</span>
{/*
The command used to run flush to the window edge and get sliced
mid-token by the truncation, so the last thing in the footer was
always half a word. It keeps a gutter now, and the full string is in
the tooltip.
*/}
<span className="selectable numeric ml-auto min-w-0 max-w-[54%] truncate pl-4 text-[10.5px]" title={rsyncFlags}>
{rsyncFlags}
</span>
</footer>
Expand Down
Loading
Loading