Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
190 changes: 166 additions & 24 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getLaunchDir } from "@/lib/launchDir";
import { usePresence } from "@/lib/usePresence";
import { isLocalhostUrl } from "@/lib/localUrl";
import { quoteShellArg } from "@/lib/shellQuote";
import { usePresence } from "@/lib/usePresence";
import { useZoom } from "@/lib/useZoom";
import { isMarkdownPath } from "@/lib/utils";
import { AgentNotificationsBridge } from "@/modules/agents";
import {
AgentRunBridge,
Expand All @@ -22,14 +24,11 @@ import {
} from "@/modules/ai";
import { AiComposerProvider } from "@/modules/ai/lib/composer";
import { native } from "@/modules/ai/lib/native";
import { CommandPalette, createCommandItems } from "@/modules/command-palette";
import {
CommandPalette,
createCommandItems,
} from "@/modules/command-palette";
import {
type EditorPaneHandle,
NewEditorDialog,
useEditorFileSync,
type EditorPaneHandle,
} from "@/modules/editor";
import { FileExplorer, type FileExplorerHandle } from "@/modules/explorer";
import type { GitHistorySearchHandle } from "@/modules/git-history";
Expand All @@ -41,50 +40,50 @@ import {
import type { PreviewPaneHandle } from "@/modules/preview";
import { openSettingsWindow } from "@/modules/settings/openSettingsWindow";
import { usePreferencesStore } from "@/modules/settings/preferences";
import { isMarkdownPath } from "@/lib/utils";
import {
useGlobalShortcuts,
type ShortcutHandlers,
type ShortcutId,
useGlobalShortcuts,
} from "@/modules/shortcuts";
import {
SidebarRail,
SIDEBAR_MAX_WIDTH,
SIDEBAR_MIN_WIDTH,
SidebarRail,
useSidebarPanel,
} from "@/modules/sidebar";
import {
SourceControlPanel,
useSourceControlContext,
} from "@/modules/source-control";
import { StatusBar } from "@/modules/statusbar";
import {
useTabs,
useWindowTitle,
useWorkspaceCwd,
} from "@/modules/tabs";
SpaceSwitcher,
useSpacePersistence,
useSpaces,
useSpacesBoot,
} from "@/modules/spaces";
import { StatusBar } from "@/modules/statusbar";
import { useTabs, useWindowTitle, useWorkspaceCwd } from "@/modules/tabs";
import { DEFAULT_SPACE_ID } from "@/modules/tabs/lib/useTabs";
import {
clearFocusedTerminal,
configureTerminalLinkActions,
disposeSession,
findLeafCwd,
hasLeaf,
leafIds,
navigateFocusedBlocks,
respawnSession,
type TerminalLinkHover,
type TerminalPaneHandle,
useTerminalFileDrop,
writeToSession,
} from "@/modules/terminal";
import {
SpaceSwitcher,
useSpaces,
useSpacePersistence,
useSpacesBoot,
} from "@/modules/spaces";
import { DEFAULT_SPACE_ID } from "@/modules/tabs/lib/useTabs";
import { ThemeProvider, useThemeFileEditing } from "@/modules/theme";
import { UpdaterDialog } from "@/modules/updater";
import { useWorkspaceEnvStore } from "@/modules/workspace";
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { SearchAddon } from "@xterm/addon-search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CloseDialogs } from "./components/CloseDialogs";
Expand Down Expand Up @@ -563,6 +562,79 @@ export default function App() {
[newPreviewTab],
);

const alwaysOpenLocalhostLinksInPreview = usePreferencesStore(
(s) => s.alwaysOpenLocalhostLinksInPreview,
);
const alwaysOpenLocalhostLinksInPreviewRef = useRef(
alwaysOpenLocalhostLinksInPreview,
);
const [localhostLinkPopup, setLocalhostLinkPopup] =
useState<TerminalLinkHover | null>(null);
const linkPopupHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
alwaysOpenLocalhostLinksInPreviewRef.current =
alwaysOpenLocalhostLinksInPreview;
}, [alwaysOpenLocalhostLinksInPreview]);

const clearLinkPopupHideTimer = useCallback(() => {
const timer = linkPopupHideTimer.current;
if (!timer) return;
clearTimeout(timer);
linkPopupHideTimer.current = null;
}, []);

const hideLinkPopupSoon = useCallback(() => {
clearLinkPopupHideTimer();
linkPopupHideTimer.current = setTimeout(() => {
setLocalhostLinkPopup(null);
linkPopupHideTimer.current = null;
}, 180);
}, [clearLinkPopupHideTimer]);

// biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally resets popup state when the active tab changes.
useEffect(() => {
clearLinkPopupHideTimer();
setLocalhostLinkPopup(null);
}, [activeId, clearLinkPopupHideTimer]);

useEffect(() => clearLinkPopupHideTimer, [clearLinkPopupHideTimer]);

useEffect(() => {
configureTerminalLinkActions({
open: (_event, uri) => {
setLocalhostLinkPopup(null);
if (
isLocalhostUrl(uri) &&
alwaysOpenLocalhostLinksInPreviewRef.current
) {
openPreviewTab(uri);
return;
}
void openUrl(uri).catch(console.error);
},
hover: (hover) => {
if (!isLocalhostUrl(hover.uri)) {
setLocalhostLinkPopup(null);
return;
}
clearLinkPopupHideTimer();
setLocalhostLinkPopup(hover);
},
leave: (uri) => {
if (isLocalhostUrl(uri)) hideLinkPopupSoon();
},
});
return () => {
configureTerminalLinkActions({
open: (_event, uri) => {
void openUrl(uri).catch(console.error);
},
hover: () => {},
leave: () => {},
});
};
}, [openPreviewTab, clearLinkPopupHideTimer, hideLinkPopupSoon]);

const splitActivePaneInActiveTab = useCallback(
(dir: "row" | "col") => {
Expand Down Expand Up @@ -871,8 +943,9 @@ export default function App() {

const handleNewTabInSpace = useCallback(
(spaceId: string) => {
const root = useSpaces.getState().spaces.find((s) => s.id === spaceId)
?.root;
const root = useSpaces
.getState()
.spaces.find((s) => s.id === spaceId)?.root;
newTabInSpace(spaceId, root ?? undefined);
},
[newTabInSpace],
Expand Down Expand Up @@ -1043,7 +1116,10 @@ export default function App() {
}}
>
<div className="flex h-full min-h-0 flex-col border-r border-border/60 bg-card">
<div key={sidebarView} className="min-h-0 flex-1 terax-panel-in">
<div
key={sidebarView}
className="min-h-0 flex-1 terax-panel-in"
>
{sidebarView === "explorer" ? (
<FileExplorer
ref={explorerRef}
Expand Down Expand Up @@ -1137,6 +1213,16 @@ export default function App() {
activeId={activeId}
onActivate={onActivateAgent}
/>
<LocalhostLinkPreviewPopup
hover={localhostLinkPopup}
onOpen={(uri) => {
clearLinkPopupHideTimer();
setLocalhostLinkPopup(null);
openPreviewTab(uri);
}}
onMouseEnter={clearLinkPopupHideTimer}
onMouseLeave={hideLinkPopupSoon}
/>
<Toaster position="bottom-right" />

{hasComposer ? (
Expand Down Expand Up @@ -1200,3 +1286,59 @@ export default function App() {

return <AiComposerProvider>{shell}</AiComposerProvider>;
}

function LocalhostLinkPreviewPopup({
hover,
onOpen,
onMouseEnter,
onMouseLeave,
}: {
hover: TerminalLinkHover | null;
onOpen: (uri: string) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}) {
if (!hover) return null;

const width = Math.max(180, Math.min(420, Math.round(hover.width || 0)));
const height = 40;
const margin = 8;
const maxLeft =
typeof window === "undefined"
? hover.x
: window.innerWidth - width - margin;
const maxTop =
typeof window === "undefined"
? hover.y
: window.innerHeight - height - margin;
const left = Math.max(margin, Math.min(hover.x, maxLeft));
const above = hover.y - height - 4;
const top = Math.max(
margin,
Math.min(above < margin ? hover.y + 18 : above, maxTop),
);

return (
<div
className="fixed z-50 rounded-md border border-border/70 bg-popover p-1 text-popover-foreground shadow-xl"
style={{ left, top, width }}
>
<button
type="button"
className="flex h-8 w-full items-center justify-center gap-2 rounded px-2 text-[12px] hover:bg-accent hover:text-accent-foreground"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={(e) => e.preventDefault()}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onClick={() => onOpen(hover.uri)}
>
<HugeiconsIcon
icon={LinkSquare02Icon}
size={14}
strokeWidth={1.75}
className="shrink-0"
/>
<span className="min-w-0 flex-1 truncate">Open in Preview</span>
</button>
</div>
);
}
20 changes: 20 additions & 0 deletions src/lib/localUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { isLocalhostUrl } from "./localUrl";

describe("isLocalhostUrl", () => {
it("accepts localhost and loopback URLs", () => {
expect(isLocalhostUrl("http://localhost:3000")).toBe(true);
expect(isLocalhostUrl("https://app.localhost/path")).toBe(true);
expect(isLocalhostUrl("http://127.0.0.1:5173")).toBe(true);
expect(isLocalhostUrl("http://127.42.0.9")).toBe(true);
expect(isLocalhostUrl("http://0.0.0.0:8000")).toBe(true);
expect(isLocalhostUrl("http://[::1]:3000")).toBe(true);
});

it("rejects external and non-http URLs", () => {
expect(isLocalhostUrl("https://example.com")).toBe(false);
expect(isLocalhostUrl("http://local-host.test")).toBe(false);
expect(isLocalhostUrl("file:///tmp/index.html")).toBe(false);
expect(isLocalhostUrl("not a url")).toBe(false);
});
});
16 changes: 16 additions & 0 deletions src/lib/localUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function isLocalhostUrl(raw: string): boolean {
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
const host = url.hostname.toLowerCase();
return (
host === "localhost" ||
host === "0.0.0.0" ||
host === "[::1]" ||
host.endsWith(".localhost") ||
/^127(?:\.\d{1,3}){3}$/.test(host)
);
} catch {
return false;
}
}
23 changes: 4 additions & 19 deletions src/modules/preview/PreviewPane.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Alert02Icon, Globe02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { isLocalhostUrl } from "@/lib/localUrl";
import {
forwardRef,
useEffect,
Expand Down Expand Up @@ -59,7 +60,7 @@ export const PreviewPane = forwardRef<PreviewPaneHandle, Props>(
[url],
);

const showXfoHint = url ? !isLocalUrl(url) : false;
const showXfoHint = url ? !isLocalhostUrl(url) : false;

return (
<div
Expand Down Expand Up @@ -171,26 +172,10 @@ function EmptyState() {
Ports
</span>{" "}
dropdown to jump straight to your running dev server. Public sites
often block embedding — open them in your browser via the link icon
if you see a blank page.
often block embedding — open them in your browser via the link icon if
you see a blank page.
</p>
</div>
</div>
);
}

function isLocalUrl(url: string): boolean {
try {
const u = new URL(url);
const h = u.hostname;
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "0.0.0.0" ||
h === "[::1]" ||
h.endsWith(".localhost")
);
} catch {
return false;
}
}
Loading