Skip to content
30 changes: 30 additions & 0 deletions src/main/app/window.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { join } from 'node:path';
import { BrowserWindow } from 'electron';
import appIcon from '@/assets/images/emdash/emdash_logo.png?asset';
import { appSettingsService } from '@main/core/settings/settings-service';
import { capture, checkAndReportDailyActiveUser } from '@main/lib/telemetry';
import { registerExternalLinkHandlers } from '@main/utils/externalLinks';
import { APP_ORIGIN } from './protocol';
Expand All @@ -24,6 +25,10 @@ export function createMainWindow(): BrowserWindow {
// Allow using <webview> in renderer for in‑app browser pane.
// The webview runs in a separate process; nodeIntegration remains disabled.
webviewTag: true,
// Enables rubber-band scrolling on macOS, which also makes Chromium
// emit horizontal wheel events for 2-finger trackpad swipes when the
// page can't scroll further — required for our swipe-nav handler.
scrollBounce: true,
// __dirname resolves to out/main/ at runtime; preload is at out/preload/index.mjs
preload: join(__dirname, '../preload/index.mjs'),
},
Expand Down Expand Up @@ -54,6 +59,31 @@ export function createMainWindow(): BrowserWindow {
checkAndReportDailyActiveUser();
});

// macOS trackpad two-finger swipe navigation (respects setting)
if (process.platform === 'darwin') {
mainWindow.on('swipe', (_event, direction) => {
void appSettingsService.get('navigation').then((navigation) => {
if (!navigation.trackpadSwipe) return;
if (direction === 'left') {
mainWindow?.webContents.send('navigate:back');
} else if (direction === 'right') {
mainWindow?.webContents.send('navigate:forward');
}
});
});
}

// Windows/Linux mouse back/forward buttons via app-command
if (process.platform !== 'darwin') {
mainWindow.on('app-command', (_event, command) => {
if (command === 'browser-backward') {
mainWindow?.webContents.send('navigate:back');
} else if (command === 'browser-forward') {
mainWindow?.webContents.send('navigate:forward');
}
});
}

// Cleanup reference on close
mainWindow.on('closed', () => {
mainWindow = null;
Expand Down
8 changes: 8 additions & 0 deletions src/main/core/settings/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export const keyboardSettingsSchema = z
closeModal: z.string().optional(),
nextProject: z.string().optional(),
prevProject: z.string().optional(),
navigateBack: z.string().optional(),
navigateForward: z.string().optional(),
newTask: z.string().optional(),
newProject: z.string().optional(),
openInEditor: z.string().optional(),
Expand Down Expand Up @@ -95,6 +97,10 @@ export const interfaceSettingsSchema = z.object({
autoRightSidebarBehavior: z.boolean(),
});

export const navigationSettingsSchema = z.object({
trackpadSwipe: z.boolean(),
});

export const browserPreviewSettingsSchema = z.object({ enabled: z.boolean() });

export const openInSettingsSchema = z.object({
Expand All @@ -111,6 +117,7 @@ export const APP_SETTINGS_SCHEMA_MAP = {
theme: themeSchema,
openIn: openInSettingsSchema,
interface: interfaceSettingsSchema,
navigation: navigationSettingsSchema,
terminal: terminalSettingsSchema,
browserPreview: browserPreviewSettingsSchema,
} as const;
Expand All @@ -124,6 +131,7 @@ export const appSettingsSchema = z.object({
theme: themeSchema,
openIn: openInSettingsSchema,
interface: interfaceSettingsSchema,
navigation: navigationSettingsSchema,
terminal: terminalSettingsSchema,
browserPreview: browserPreviewSettingsSchema,
});
3 changes: 3 additions & 0 deletions src/main/core/settings/settings-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export const SETTINGS_DEFAULTS = {
taskHoverAction: 'delete' as const,
autoRightSidebarBehavior: false,
},
navigation: {
trackpadSwipe: true,
},
browserPreview: {
enabled: true,
},
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/components/AppKeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useHotkey } from '@tanstack/react-hotkeys';
import { useAppSettingsKey } from '@renderer/core/app/use-app-settings-key';
import { useShowModal } from '@renderer/core/modal/modal-provider';
import { useWorkspaceLayoutContext } from '@renderer/core/view/layout-provider';
import { useNavigationHistory } from '@renderer/core/view/navigation-history-provider';
import { useNavigate, useParams, useWorkspaceSlots } from '@renderer/core/view/navigation-provider';
import { getEffectiveHotkey } from '@renderer/hooks/useKeyboardShortcuts';
import { useTheme } from '@renderer/hooks/useTheme';
Expand All @@ -19,6 +20,7 @@ export function AppKeyboardShortcuts() {
const { toggleLeft, toggleRight } = useWorkspaceLayoutContext();
const { toggleTheme } = useTheme();
const { navigate } = useNavigate();
const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory();

// Resolve current project context from whichever view is active
const { currentView } = useWorkspaceSlots();
Expand All @@ -45,6 +47,14 @@ export function AppKeyboardShortcuts() {
showNewProject({ strategy: 'local', mode: 'pick' })
);

useHotkey(getEffectiveHotkey('navigateBack', keyboard), () => goBack(), {
enabled: canGoBack,
});

useHotkey(getEffectiveHotkey('navigateForward', keyboard), () => goForward(), {
enabled: canGoForward,
});

useHotkey(
getEffectiveHotkey('newTask', keyboard),
() => {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/components/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import TelemetryCard from './TelemetryCard';
import TerminalSettingsCard from './TerminalSettingsCard';
import ThemeCard from './ThemeCard';
import { TrackpadNavigationSettingsCard } from './TrackpadNavigationSettingsCard';
import { UpdateCard } from './UpdateCard';

export type SettingsPageTab =
Expand Down Expand Up @@ -125,6 +126,7 @@ export function SettingsPage({
sections: [
{ component: <ThemeCard /> },
{ component: <TerminalSettingsCard /> },
{ title: 'Navigation', component: <TrackpadNavigationSettingsCard /> },
{ title: 'Keyboard shortcuts', component: <KeyboardSettingsCard /> },
{
title: 'Tools',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useAppSettingsKey } from '@renderer/core/app/use-app-settings-key';
import { Switch } from '../ui/switch';

export function TrackpadNavigationSettingsCard() {
const { value, update, isLoading, isSaving } = useAppSettingsKey('navigation');

return (
<div className="flex items-center justify-between gap-4 rounded-xl border border-border/60 bg-muted/10 p-4">
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">Trackpad swipe navigation</span>
<span className="text-sm text-muted-foreground">
Navigate back and forward between views using trackpad swipe gestures.
</span>
</div>
<Switch
checked={value?.trackpadSwipe ?? true}
disabled={isLoading || isSaving}
onCheckedChange={(trackpadSwipe) => update({ trackpadSwipe })}
/>
</div>
);
}
43 changes: 41 additions & 2 deletions src/renderer/components/titlebar/Titlebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { PanelLeft, PanelRight } from 'lucide-react';
import { ChevronLeft, ChevronRight, PanelLeft, PanelRight } from 'lucide-react';
import { ReactNode } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Toggle } from '@renderer/components/ui/toggle';
import { useWorkspaceLayoutContext } from '@renderer/core/view/layout-provider';
import { useNavigationHistory } from '@renderer/core/view/navigation-history-provider';
import { useWorkspaceSlots } from '@renderer/core/view/navigation-provider';
import { cn } from '@renderer/lib/utils';
import ShortcutHint from '../ui/shortcut-hint';
Expand All @@ -10,6 +12,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export function Titlebar({ leftSlot, rightSlot }: { leftSlot?: ReactNode; rightSlot?: ReactNode }) {
const { isRightOpen, setCollapsed, isLeftOpen } = useWorkspaceLayoutContext();
const { RightPanel } = useWorkspaceSlots();
const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory();
return (
<header
className={cn(
Expand All @@ -20,7 +23,43 @@ export function Titlebar({ leftSlot, rightSlot }: { leftSlot?: ReactNode; rightS
<div className="pointer-events-auto flex w-full items-center gap-1">
{!isLeftOpen && <div className="[-webkit-app-region:no-drag]"></div>}
<div className="flex w-full items-center justify-between">
<div className="flex items-center justify-start [-webkit-app-region:no-drag]">
<div className="flex items-center justify-start gap-0.5 [-webkit-app-region:no-drag]">
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={!canGoBack}
onClick={() => goBack()}
aria-label="Go back"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Go back
<ShortcutHint settingsKey="navigateBack" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground disabled:opacity-40"
disabled={!canGoForward}
onClick={() => goForward()}
aria-label="Go forward"
>
<ChevronRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Go forward
<ShortcutHint settingsKey="navigateForward" />
</TooltipContent>
</Tooltip>
{!isLeftOpen && (
<Tooltip>
<TooltipTrigger>
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/core/stores/navigation-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { makeAutoObservable, toJS } from 'mobx';
import type { NavigationSnapshot } from '@shared/view-state';
import type { ViewId, WrapParams } from '../view/registry';
import { views, type ViewId, type WrapParams } from '../view/registry';
import type { Snapshottable } from './snapshottable';

type ViewParamsStore = Partial<{ [K in ViewId]: WrapParams<K> }>;
Expand All @@ -26,7 +26,9 @@ export class NavigationStore implements Snapshottable<NavigationSnapshot> {
}

restoreSnapshot(snapshot: Partial<NavigationSnapshot>): void {
if (snapshot.currentViewId) this.currentViewId = snapshot.currentViewId as ViewId;
if (snapshot.currentViewId && snapshot.currentViewId in views) {
this.currentViewId = snapshot.currentViewId as ViewId;
}
if (snapshot.viewParams) this.viewParamsStore = snapshot.viewParams as ViewParamsStore;
}
}
Loading