Skip to content

Commit 01f32e6

Browse files
Merge pull request #2505 from generalaction/jan/eng-1589-add-back-and-forward-navigation-to-in-app-browser
feat(browser): add history navigation
2 parents 10bbe1a + e8820c1 commit 01f32e6

5 files changed

Lines changed: 142 additions & 15 deletions

File tree

apps/emdash-desktop/src/renderer/features/browser/browser-pane.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,16 @@ export const BrowserPane = observer(function BrowserPane({ browserId }: { browse
125125
[loadUrl]
126126
);
127127

128+
const goBack = useCallback(() => {
129+
if (!adapter?.canGoBack()) return;
130+
adapter.goBack();
131+
}, [adapter]);
132+
133+
const goForward = useCallback(() => {
134+
if (!adapter?.canGoForward()) return;
135+
adapter.goForward();
136+
}, [adapter]);
137+
128138
const reload = useCallback(() => {
129139
if (!session) return;
130140
const decision = decideBrowserReload({
@@ -209,6 +219,8 @@ export const BrowserPane = observer(function BrowserPane({ browserId }: { browse
209219
adapter={adapter}
210220
autoFocusUrl={showStartPage}
211221
onNavigate={navigateTo}
222+
onGoBack={goBack}
223+
onGoForward={goForward}
212224
onReload={reload}
213225
onForceReload={forceReload}
214226
onSetZoomFactor={setZoomFactor}

apps/emdash-desktop/src/renderer/features/browser/browser-toolbar.tsx

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
ArrowLeft,
3+
ArrowRight,
24
Ellipsis,
35
Focus,
46
Globe,
@@ -9,7 +11,7 @@ import {
911
RotateCcw,
1012
Square,
1113
} from 'lucide-react';
12-
import { useEffect, useRef, useState, type ReactNode } from 'react';
14+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
1315
import { rpc } from '@renderer/lib/ipc';
1416
import { Button } from '@renderer/lib/ui/button';
1517
import {
@@ -49,6 +51,8 @@ export function BrowserToolbar({
4951
adapter,
5052
autoFocusUrl,
5153
onNavigate,
54+
onGoBack,
55+
onGoForward,
5256
onReload,
5357
onForceReload,
5458
onSetZoomFactor,
@@ -58,6 +62,8 @@ export function BrowserToolbar({
5862
adapter: BrowserWebviewAdapter | null;
5963
autoFocusUrl?: boolean;
6064
onNavigate?: (url: string) => boolean;
65+
onGoBack?: () => void;
66+
onGoForward?: () => void;
6167
onReload?: () => void;
6268
onForceReload?: () => void;
6369
onSetZoomFactor?: (factor: number) => void;
@@ -66,8 +72,7 @@ export function BrowserToolbar({
6672
const [urlText, setUrlText] = useState(browserUrlInputText(session.currentUrl));
6773
const [urlError, setUrlError] = useState<string | null>(null);
6874
const [failedFaviconUrl, setFailedFaviconUrl] = useState<string | null>(null);
69-
const [screenshotSpin, setScreenshotSpin] = useState(false);
70-
const screenshotSpinTimerRef = useRef<number | null>(null);
75+
const [screenshotSpin, triggerScreenshotSpin] = useTransientFlag(300);
7176
const urlInputRef = useRef<HTMLInputElement | null>(null);
7277
const faviconUrl =
7378
session.faviconUrl && session.faviconUrl !== failedFaviconUrl ? session.faviconUrl : null;
@@ -124,26 +129,29 @@ export function BrowserToolbar({
124129
};
125130

126131
const takeScreenshot = () => {
127-
setScreenshotSpin(true);
128-
if (screenshotSpinTimerRef.current !== null) {
129-
window.clearTimeout(screenshotSpinTimerRef.current);
130-
}
131-
screenshotSpinTimerRef.current = window.setTimeout(() => setScreenshotSpin(false), 300);
132+
triggerScreenshotSpin();
132133
void captureBrowserScreenshot(session);
133134
};
134135

135-
useEffect(() => {
136-
return () => {
137-
if (screenshotSpinTimerRef.current !== null) {
138-
window.clearTimeout(screenshotSpinTimerRef.current);
139-
}
140-
};
141-
}, []);
142136
const canOpenExternal = canOpenBrowserUrlExternally(session.currentUrl);
143137
const zoomFactor = session.zoomFactor;
144138

145139
return (
146140
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-background-secondary-1 px-2">
141+
<ToolbarIconButton
142+
label="Back"
143+
disabled={!adapter || !session.canGoBack}
144+
onClick={() => onGoBack?.()}
145+
>
146+
<ArrowLeft className="size-4" />
147+
</ToolbarIconButton>
148+
<ToolbarIconButton
149+
label="Forward"
150+
disabled={!adapter || !session.canGoForward}
151+
onClick={() => onGoForward?.()}
152+
>
153+
<ArrowRight className="size-4" />
154+
</ToolbarIconButton>
147155
<ToolbarIconButton label={session.isLoading ? 'Stop' : 'Reload'} onClick={() => onReload?.()}>
148156
{session.isLoading ? <Square className="size-3.5" /> : <RefreshCw className="size-4" />}
149157
</ToolbarIconButton>
@@ -303,6 +311,26 @@ export function BrowserToolbar({
303311
);
304312
}
305313

314+
/** Returns a flag that turns on when triggered and resets itself after `durationMs`. */
315+
function useTransientFlag(durationMs: number): [boolean, () => void] {
316+
const [active, setActive] = useState(false);
317+
const timerRef = useRef<number | null>(null);
318+
319+
const trigger = useCallback(() => {
320+
setActive(true);
321+
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
322+
timerRef.current = window.setTimeout(() => setActive(false), durationMs);
323+
}, [durationMs]);
324+
325+
useEffect(() => {
326+
return () => {
327+
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
328+
};
329+
}, []);
330+
331+
return [active, trigger];
332+
}
333+
306334
function ToolbarIconButton({
307335
label,
308336
disabled,

apps/emdash-desktop/src/renderer/features/tasks/commands.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
99
getTaskGitStore: vi.fn(),
1010
getTaskStore: vi.fn(),
1111
getTaskView: vi.fn(),
12+
goBack: vi.fn(),
13+
goForward: vi.fn(),
1214
navigate: vi.fn(),
1315
openExternal: vi.fn(),
1416
reload: vi.fn(),
@@ -22,6 +24,10 @@ vi.mock('@renderer/features/browser/browser-controls-registry', () => ({
2224
browserControlsRegistry: {
2325
get: vi.fn(() => ({
2426
adapter: {
27+
canGoBack: () => true,
28+
canGoForward: () => true,
29+
goBack: mocks.goBack,
30+
goForward: mocks.goForward,
2531
reload: mocks.reload,
2632
},
2733
focusUrl: mocks.focusUrl,
@@ -218,4 +224,43 @@ describe('createTaskCommandProvider', () => {
218224
expect(mocks.openExternal).toHaveBeenCalledWith('https://example.com/');
219225
expect(mocks.writeText).toHaveBeenCalledWith('https://example.com/');
220226
});
227+
228+
it('navigates browser history through the browser controls registry', () => {
229+
const taskView = mocks.getTaskView();
230+
const tab = activeBrowserTab();
231+
tab.session.canGoBack = true;
232+
tab.session.canGoForward = true;
233+
taskView.tabManager.resolvedTabs = [tab];
234+
mocks.getTaskView.mockReturnValue(taskView);
235+
const provider = createTaskCommandProvider('project-1', 'task-1');
236+
237+
const commands = provider.getCommands();
238+
const goBack = commands.find((candidate) => candidate.id === 'task.browserGoBack');
239+
const goForward = commands.find((candidate) => candidate.id === 'task.browserGoForward');
240+
241+
expect(goBack?.enabled).toBe(true);
242+
expect(goForward?.enabled).toBe(true);
243+
244+
goBack?.execute();
245+
goForward?.execute();
246+
247+
expect(mocks.goBack).toHaveBeenCalledWith();
248+
expect(mocks.goForward).toHaveBeenCalledWith();
249+
});
250+
251+
it('disables browser history commands when the session has no history', () => {
252+
const taskView = mocks.getTaskView();
253+
taskView.tabManager.resolvedTabs = [activeBrowserTab()];
254+
mocks.getTaskView.mockReturnValue(taskView);
255+
const provider = createTaskCommandProvider('project-1', 'task-1');
256+
257+
const commands = provider.getCommands();
258+
259+
expect(commands.find((candidate) => candidate.id === 'task.browserGoBack')?.enabled).toBe(
260+
false
261+
);
262+
expect(commands.find((candidate) => candidate.id === 'task.browserGoForward')?.enabled).toBe(
263+
false
264+
);
265+
});
221266
});

apps/emdash-desktop/src/renderer/features/tasks/commands.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export function createTaskCommandProvider(projectId: string, taskId: string): Co
5959
const toggleRightSidebarDef = taskDef('task.toggleRightSidebar');
6060
const newTerminalDef = taskDef('task.newTerminal');
6161
const openBrowserDef = taskDef('task.openBrowser');
62+
const browserGoBackDef = taskDef('task.browserGoBack');
63+
const browserGoForwardDef = taskDef('task.browserGoForward');
6264
const browserReloadDef = taskDef('task.browserReload');
6365
const browserFocusUrlDef = taskDef('task.browserFocusUrl');
6466
const browserOpenExternalDef = taskDef('task.browserOpenExternal');
@@ -196,6 +198,30 @@ export function createTaskCommandProvider(projectId: string, taskId: string): Co
196198
taskView?.setFocusedRegion('main');
197199
},
198200
},
201+
{
202+
id: browserGoBackDef.id,
203+
label: browserGoBackDef.label,
204+
description: browserGoBackDef.description,
205+
group: browserGoBackDef.group,
206+
enabled: activeBrowserTab?.kind === 'browser' && activeBrowserTab.session.canGoBack,
207+
execute() {
208+
if (activeBrowserTab?.kind !== 'browser') return;
209+
const adapter = browserControlsRegistry.get(activeBrowserTab.browserId)?.adapter;
210+
if (adapter?.canGoBack()) adapter.goBack();
211+
},
212+
},
213+
{
214+
id: browserGoForwardDef.id,
215+
label: browserGoForwardDef.label,
216+
description: browserGoForwardDef.description,
217+
group: browserGoForwardDef.group,
218+
enabled: activeBrowserTab?.kind === 'browser' && activeBrowserTab.session.canGoForward,
219+
execute() {
220+
if (activeBrowserTab?.kind !== 'browser') return;
221+
const adapter = browserControlsRegistry.get(activeBrowserTab.browserId)?.adapter;
222+
if (adapter?.canGoForward()) adapter.goForward();
223+
},
224+
},
199225
{
200226
id: browserReloadDef.id,
201227
label: browserReloadDef.label,

apps/emdash-desktop/src/shared/commands.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,22 @@ export const TASK_COMMAND_DEFS = defineCommandDefs([
182182
group: 'Browser',
183183
iconKey: 'globe',
184184
},
185+
{
186+
id: 'task.browserGoBack',
187+
label: 'Browser Back',
188+
description: 'Go back in the active browser tab',
189+
scope: 'task',
190+
group: 'Browser',
191+
iconKey: 'arrow-left',
192+
},
193+
{
194+
id: 'task.browserGoForward',
195+
label: 'Browser Forward',
196+
description: 'Go forward in the active browser tab',
197+
scope: 'task',
198+
group: 'Browser',
199+
iconKey: 'arrow-right',
200+
},
185201
{
186202
id: 'task.browserReload',
187203
label: 'Reload Browser',

0 commit comments

Comments
 (0)