Skip to content
Merged
6 changes: 6 additions & 0 deletions apps/web/src/components/Layout/tabs/LinkButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ function LinkButtonFn<
state.tabs.tabList.find((t) => t.id === state.tabs.activeTabId[state.tabs.activePane]),
);
const activePane = useSelector((state) => state.tabs.activePane);
const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget);
const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview);
const primaryTabList = useSelector((state) =>
state.tabs.tabList.filter((t) => state.tabs.paneTabIds.primary.includes(t.id)),
);
Expand Down Expand Up @@ -113,6 +115,10 @@ function LinkButtonFn<
isShiftHeld: event.shiftKey,
newTab,
newSplitTab,
// LinkButton renders UI chrome (the space switcher), never document content.
isInDocument: false,
documentLinkTarget,
splitTabsArePreview,
});

switch (action.type) {
Expand Down
76 changes: 70 additions & 6 deletions apps/web/src/components/Layout/tabs/TabSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { useSelector, useActions } from '@/store';
import { useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useRef } from 'react';
import { resolveModifier, useKeyHold } from '@tanstack/react-hotkeys';
import { useQueryClient } from '@tanstack/react-query';
import { getDocumentByIdQueryOptions } from '@/queries/documents';
import { matchTabLocation, findGroupTab, resolveTabAction } from './utils';

/**
Expand All @@ -18,6 +20,7 @@ import { matchTabLocation, findGroupTab, resolveTabAction } from './utils';
*/
export function TabSync() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { openTab, updateTab, setActiveTab } = useActions();

const tabList = useSelector((state) => state.tabs.tabList);
Expand All @@ -32,6 +35,8 @@ export function TabSync() {
state.tabs.tabList.filter((t) => state.tabs.paneTabIds.secondary.includes(t.id)),
);
const activePane = useSelector((state) => state.tabs.activePane);
const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget);
const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview);
const isDocumentTab =
activeTab &&
(activeTab.pathname.startsWith('/edit/') || activeTab.pathname.startsWith('/view/'));
Expand All @@ -50,24 +55,68 @@ export function TabSync() {
const activeTabRef = useRef(activeTab);
const isModifierHeldRef = useRef(isModifierHeld);
const isShiftHeldRef = useRef(isShiftHeld);
const documentLinkTargetRef = useRef(documentLinkTarget);
const splitTabsArePreviewRef = useRef(splitTabsArePreview);
useEffect(() => {
primaryTabListRef.current = primaryTabList;
secondaryTabListRef.current = secondaryTabList;
activePaneRef.current = activePane;
activeTabRef.current = activeTab;
isModifierHeldRef.current = isModifierHeld;
isShiftHeldRef.current = isShiftHeld;
}, [primaryTabList, secondaryTabList, activePane, activeTab, isModifierHeld, isShiftHeld]);
documentLinkTargetRef.current = documentLinkTarget;
splitTabsArePreviewRef.current = splitTabsArePreview;
}, [
primaryTabList,
secondaryTabList,
activePane,
activeTab,
isModifierHeld,
isShiftHeld,
documentLinkTarget,
splitTabsArePreview,
]);

useEffect(() => {
// In-document links address documents by id (`/view/<id>?id=true`) so they
// survive renames. Resolve that to the handle before routing, so the tab is
// created with its canonical location: tab matching works against open
// tabs, and nothing ever looks the id up as a handle.
const resolveDocumentLocation = async (
pathname: string,
search: Record<string, string>,
): Promise<{ pathname: string; search: Record<string, string> }> => {
const isDocumentPath = pathname.startsWith('/view/') || pathname.startsWith('/edit/');
const id = pathname.split('/').pop();
if (!search.id || !isDocumentPath || !id) return { pathname, search };
try {
const document = await queryClient.ensureQueryData(
getDocumentByIdQueryOptions(id, queryClient),
);
const { id: _id, ...rest } = search;
return { pathname: pathname.replace(id, document.handle), search: rest };
} catch {
return { pathname, search };
}
};

const handleLinkClick = (event: MouseEvent) => {
const link = event.currentTarget as HTMLAnchorElement;
const { origin, pathname, searchParams, hash } = new URL(link.href);
const { origin, pathname: rawPathname, searchParams, hash } = new URL(link.href);
if (origin !== location.origin) return;
if (link.download) return;
const search = Object.fromEntries(searchParams.entries());
const normalizedHash = hash.slice(1);
event.preventDefault();
void resolveDocumentLocation(rawPathname, Object.fromEntries(searchParams.entries())).then(
({ pathname, search }) => routeLink(link, pathname, search, hash.slice(1)),
);
};

const routeLink = (
link: HTMLAnchorElement,
pathname: string,
search: Record<string, string>,
normalizedHash: string,
) => {
const action = resolveTabAction({
pathname,
search,
Expand All @@ -80,10 +129,11 @@ export function TabSync() {
isShiftHeld: isShiftHeldRef.current,
newTab: link.dataset.newTab === 'true',
newSplitTab: link.dataset.newSplitTab === 'true',
isInDocument: !!link.closest('.editor-input'),
documentLinkTarget: documentLinkTargetRef.current,
splitTabsArePreview: splitTabsArePreviewRef.current,
});

event.preventDefault();

switch (action.type) {
case 'activate':
setActiveTab(action.tabId);
Expand Down Expand Up @@ -179,6 +229,20 @@ export function TabSync() {
if (pathname.startsWith('/login') || pathname.startsWith('/signup')) return;
const locationMatches = activeTab && matchTabLocation(activeTab, pathname, search, hash);
if (locationMatches) return;
// A tab opened from an in-document link holds `/view/<id>?id=true`; the route
// redirects it to the handle. That redirect belongs to the active tab, so it
// is updated in place (keeping its preview state) — checked before any other
// tab with the same location, which would otherwise steal the navigation.
const isIdRedirect =
!!activeTab?.search?.id &&
isDocumentTab &&
queryClient.getQueryData<{ handle?: string }>(
getDocumentByIdQueryOptions(documentHandle ?? '').queryKey,
)?.handle === pathname.split('/').pop();
if (isIdRedirect) {
updateTab(activeTab.id, { pathname, search, hash });
return;
}
const existingTab = tabList.find((t) => matchTabLocation(t, pathname, search, hash));
if (existingTab) return setActiveTab(existingTab.id);
const existingGroupTab = findGroupTab(tabList, pathname);
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/Layout/tabs/resolveTabMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import type { TabMetadata } from '@repo/types';
import { getDocumentByHandleQueryOptions } from '@/queries/documents';
import { getDocumentByHandleQueryOptions, getDocumentByIdQueryOptions } from '@/queries/documents';
import type { UseQueryOptions } from '@tanstack/react-query';

export type TabMetadataQueryOption = UseQueryOptions<any, any, TabMetadata>;
Expand Down Expand Up @@ -74,7 +74,12 @@ export function resolveTabMetadata(
// 3. Document routes (dynamic — title & icon come from a query)
const handle = getDocumentHandle(pathname);
if (handle) {
const docQueryOpts = getDocumentByHandleQueryOptions(handle);
// In-document links carry the document id (`?id=true`) and are redirected
// to the handle by the route; looking the id up as a handle 404s, and a
// failed lookup auto-closes the tab.
const docQueryOpts = search?.id
? getDocumentByIdQueryOptions(handle)
: getDocumentByHandleQueryOptions(handle);
return {
metadata: { title: '', icon: null }, // fallback while loading
queryOption: {
Expand Down
42 changes: 35 additions & 7 deletions apps/web/src/components/Layout/tabs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { Tab } from '@repo/types';
import type { DocumentLinkTarget } from '@/store/ui-slice';

export const matchTabLocation = (
tab: Tab,
Expand Down Expand Up @@ -113,6 +114,10 @@ export interface ResolveTabActionInput {
isShiftHeld: boolean;
newTab: boolean;
newSplitTab: boolean;
/** True when the clicked link lives inside document content (`.editor-input`). */
isInDocument: boolean;
documentLinkTarget: DocumentLinkTarget;
splitTabsArePreview: boolean;
}

export type TabAction =
Expand All @@ -126,7 +131,7 @@ export type TabAction =
}
| {
type: 'preview';
pane: 'primary' | 'secondary';
pane: 'primary' | 'secondary' | 'opposite';
pathname: string;
search: Record<string, unknown>;
hash: string;
Expand Down Expand Up @@ -162,6 +167,11 @@ export type TabAction =
* - The exact-match (`existingTab`) branch is only gated on `!shouldOpenNewTab`,
* allowing split navigation to activate an existing tab in the target pane
* rather than creating a duplicate.
* - When `documentLinkTarget` is 'split-view', Shift inverts for links inside
* document content: a plain click splits and Shift+Click stays in the current
* pane. An explicit `data-new-split-tab` still forces a split either way.
* - Split opens are preview-eligible when `splitTabsArePreview` is set; the
* store scopes preview replacement per pane, so each pane keeps its own.
*/
export const resolveTabAction = ({
pathname,
Expand All @@ -175,17 +185,28 @@ export const resolveTabAction = ({
isShiftHeld,
newTab,
newSplitTab,
isInDocument,
documentLinkTarget,
splitTabsArePreview,
}: ResolveTabActionInput): TabAction => {
// Two different intents: `newTab` (the editor marks every in-document link
// with data-new-tab) only means "never replace the document being read";
// Ctrl/Cmd means "a permanent new tab". Both block in-place navigation, only
// the modifier blocks preview and split-by-default.
const shouldOpenNewTab = isModifierHeld || newTab;
const shouldSplitTab = isShiftHeld || newSplitTab;
const splitByDefault = documentLinkTarget === 'split-view' && isInDocument && !isModifierHeld;
const shouldSplitTab = (splitByDefault ? !isShiftHeld : isShiftHeld) || newSplitTab;

const activePaneTabList = activePane === 'secondary' ? secondaryTabList : primaryTabList;
const oppositePaneTabList = activePane === 'secondary' ? primaryTabList : secondaryTabList;
// Fix: use shouldSplitTab so newSplitTab prop influences which pane is searched
const targetTabList = shouldSplitTab ? oppositePaneTabList : activePaneTabList;

const isViewLink = pathname.startsWith('/view/');
const isPreviewEligible = isViewLink && !shouldOpenNewTab && !shouldSplitTab;
// A same-pane open of a data-new-tab link stays a permanent tab, as before;
// a split open is preview when the preference says so.
const isPreviewEligible =
isViewLink && !isModifierHeld && (shouldSplitTab ? splitTabsArePreview : !newTab);

const existingTab =
targetTabList.find((t) => matchTabLocation(t, pathname, search, hash)) ?? null;
Expand All @@ -198,16 +219,23 @@ export const resolveTabAction = ({
: null;

// Honor new-tab/new-split-tab requests across group and same-path reuse branches.
// Exact-match tabs may still be activated when splitting — only forced new-tab (Ctrl/newTab)
// blocks that, because the existing tab is already in the target pane.
// An exact-match tab in the target pane is activated unless Ctrl/Cmd forces a
// new tab: a data-new-tab link only forbids replacing the reader's tab, and
// activating the target's own tab does not — opening another would duplicate it.
if (existingGroupTab && !shouldOpenNewTab && !shouldSplitTab) {
return { type: 'activate-and-update', tabId: existingGroupTab.id, pathname, search, hash };
} else if (existingTabSamePath && !shouldOpenNewTab && !shouldSplitTab) {
return { type: 'activate-and-update', tabId: existingTabSamePath.id, pathname, search, hash };
} else if (existingTab && !shouldOpenNewTab) {
} else if (existingTab && !isModifierHeld) {
return { type: 'activate', tabId: existingTab.id };
} else if (isPreviewEligible) {
return { type: 'preview', pane: activePane, pathname, search, hash };
return {
type: 'preview',
pane: shouldSplitTab ? 'opposite' : activePane,
pathname,
search,
hash,
};
} else if (!(shouldOpenNewTab || shouldSplitTab) && activeTab) {
const isDocumentLink = pathname.startsWith('/edit/') || pathname.startsWith('/view/');
const requiresAutosave =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,28 @@ import { THEME_BY_VALUE } from '@repo/ui/theme/themes';
import { cn } from '@repo/ui/lib/utils';
import {
CircleOff,
Columns2,
FoldHorizontal,
Palette,
RefreshCcw,
Square,
UnfoldHorizontal,
} from '@repo/ui/components/icons';
import type { DocumentLinkTarget } from '@/store/ui-slice';

function InterfacePreferencesSettings() {
const appSidebar = useSelector((state) => state.ui.appSidebar);
const documentSidebar = useSelector((state) => state.ui.documentSidebar);
const folderColorsEnabled = useSelector((state) => state.ui.folderColorsEnabled);
const folderDefaultColor = useSelector((state) => state.ui.folderDefaultColor);
const folderColorSolid = useSelector((state) => state.ui.folderColorSolid);
const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget);
const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview);
const {
setAppSidebar,
setDocumentSidebar,
setDocumentLinkTarget,
setSplitTabsArePreview,
setFolderColorsEnabled,
setFolderDefaultColor,
setFolderColorSolid,
Expand All @@ -57,7 +64,7 @@ function InterfacePreferencesSettings() {
defaultValue={appSidebar}
onValueChange={(value) => setAppSidebar(value as 'expanded' | 'collapsed' | 'remember')}
>
<SelectTrigger className="">
<SelectTrigger id="appSidebar">
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
Expand Down Expand Up @@ -90,7 +97,7 @@ function InterfacePreferencesSettings() {
setDocumentSidebar(value as 'expanded' | 'collapsed' | 'remember')
}
>
<SelectTrigger>
<SelectTrigger id="documentSidebar">
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
Expand Down Expand Up @@ -194,6 +201,50 @@ function InterfacePreferencesSettings() {
</div>
</div>
)}

<Separator className="bg-transparent border-t border-dashed h-0" />

<div className="flex items-center justify-between">
<div>
<Label htmlFor="documentLinkTarget">Open Links Inside Documents</Label>
<p className="text-sm text-muted-foreground">
Where a link in a document opens; Shift+Click does the opposite
</p>
</div>
<Select
value={documentLinkTarget}
onValueChange={(value) => setDocumentLinkTarget(value as DocumentLinkTarget)}
>
<SelectTrigger id="documentLinkTarget">
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="current-pane" className="flex items-center gap-2">
<Square className="text-foreground" />
Current Pane
</SelectItem>
<SelectItem value="split-view" className="flex items-center gap-2">
<Columns2 className="text-foreground" />
Split View
</SelectItem>
</SelectContent>
</Select>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>

<div className="flex items-center justify-between">
<div>
<Label htmlFor="splitTabsArePreview">Split-View Tabs Are Preview Tabs</Label>
<p className="text-sm text-muted-foreground">
Preview tabs show in italics and are replaced by the next one — double-click a tab to
keep it
</p>
</div>
<Switch
id="splitTabsArePreview"
checked={splitTabsArePreview}
onCheckedChange={setSplitTabsArePreview}
/>
</div>
</CardContent>
</Card>
);
Expand Down
Loading
Loading