Skip to content
Open
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
31 changes: 31 additions & 0 deletions scripts/settingsPersistence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const {
EDITOR_MAX_WIDTH_RANGE,
PREVIEW_FONT_SIZE_RANGE,
SettingsStore,
TAB_COLUMN_WIDTH_RANGE,
ZOOM_LEVEL_RANGE,
clampToRange,
createSettingsPersistence,
Expand Down Expand Up @@ -1107,3 +1108,33 @@ test('no settings row buries a sized control below its own flex level', () => {
const buried = rows.flatMap((row) => row.nested.map((found) => `${row.label}: ${found}`));
assert.deepEqual(buried, [], 'a sized control must be a direct child of its .setting-item');
});

test('tab position survives a restart and an unknown value keeps the strip on top (#884)', () => {
for (const position of ['left', 'right', 'top'] as const) {
resetStorage({ 'editor.tabPosition': position });
assert.equal(createStore().tabPosition, position);
}
for (const stored of ['bottom', '', 'LEFT', 'null']) {
resetStorage({ 'editor.tabPosition': stored });
assert.equal(createStore().tabPosition, 'top', `editor.tabPosition=${JSON.stringify(stored)}`);
}
resetStorage();
assert.equal(createStore().tabPosition, 'top', 'a fresh install keeps the tabs where they were');
});

test('the tab column width is clamped to its shared range on load and on resize', () => {
resetStorage({ 'editor.tabColumnWidth': '9000' });
const store = createStore();
assert.equal(store.tabColumnWidth, TAB_COLUMN_WIDTH_RANGE.max);
store.setTabColumnWidth(10);
assert.equal(store.tabColumnWidth, TAB_COLUMN_WIDTH_RANGE.min);
resetStorage({ 'editor.tabColumnWidth': 'banana' });
assert.equal(createStore().tabColumnWidth, TAB_COLUMN_WIDTH_RANGE.default);
});

test('the tab position select offers exactly the values the store accepts', () => {
const select = sliceBetween(componentSource, 'id="appearance-tab-position"', '</select>');
const offered = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(offered, ['top', 'left', 'right']);
for (const value of offered) assert.ok(settingsModule.isTabPosition(value), value);
});
9 changes: 5 additions & 4 deletions scripts/singleImplementationConvention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,14 @@ const RULES: Rule[] = [
allowed: [],
},
{
name: 'the TOC separator advertises the settings range',
name: 'a resize separator advertises its settings range',
why: 'aria-valuemin/max is the bound assistive tech reports; a literal there goes stale silently because no visual check can see it. This is the half of the TOC rule above that a differently-named copy would escape.',
marker: /aria-valuemin=/g,
allowed: ['src/lib/MarkdownViewer.svelte'],
// TabColumn.svelte's separator advertises TAB_COLUMN_WIDTH_RANGE, its own setting's range (#884).
allowed: ['src/lib/MarkdownViewer.svelte', 'src/lib/components/TabColumn.svelte'],
requires: {
pattern: /aria-valuemin=\{TOC_WIDTH_RANGE\.min\}\s*\n\s*aria-valuemax=\{TOC_WIDTH_RANGE\.max\}/,
message: 'the resize separator must advertise TOC_WIDTH_RANGE.min/.max, not numbers of its own',
pattern: /aria-valuemin=\{([A-Z_]+_WIDTH_RANGE)\.min\}\s*\n\s*aria-valuemax=\{\1\.max\}/,
message: 'the resize separator must advertise its setting\'s *_WIDTH_RANGE.min/.max, not numbers of its own',
},
},
{
Expand Down
32 changes: 32 additions & 0 deletions scripts/tabReorder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { closestSlotIndex, dragDistance } from '../src/lib/utils/tabReorder.js';

// Three 100x30 tabs, laid out across the strip and then down the column (#884).
const row = [0, 1, 2].map((i) => ({ left: i * 104, top: 0, width: 100, height: 30 }));
const column = [0, 1, 2].map((i) => ({ left: 0, top: i * 32, width: 180, height: 30 }));

test('the strip picks the slot nearest the pointer horizontally', () => {
assert.equal(closestSlotIndex(row, { x: 10, y: 500 }, 'x'), 0);
assert.equal(closestSlotIndex(row, { x: 160, y: 500 }, 'x'), 1);
assert.equal(closestSlotIndex(row, { x: 999, y: 0 }, 'x'), 2);
});

test('the column picks the slot nearest the pointer vertically, whatever its x', () => {
assert.equal(closestSlotIndex(column, { x: 500, y: 5 }, 'y'), 0);
assert.equal(closestSlotIndex(column, { x: -40, y: 40 }, 'y'), 1);
assert.equal(closestSlotIndex(column, { x: 90, y: 400 }, 'y'), 2);
});

test('non-tab children are skipped and an empty list has no slot', () => {
assert.equal(closestSlotIndex([null, column[1], null], { x: 0, y: 0 }, 'y'), 1);
assert.equal(closestSlotIndex([], { x: 0, y: 0 }, 'x'), -1);
});

test('a drag starts on travel along the layout axis only', () => {
const start = { x: 100, y: 100 };
assert.equal(dragDistance(start, { x: 108, y: 100 }, 'x'), 8);
assert.equal(dragDistance(start, { x: 108, y: 100 }, 'y'), 0);
assert.equal(dragDistance(start, { x: 100, y: 93 }, 'y'), 7);
});
65 changes: 49 additions & 16 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { open, save, ask } from '@tauri-apps/plugin-dialog';
import Settings from './components/Settings.svelte';
import TitleBar from './components/TitleBar.svelte';
import TabColumn from './components/TabColumn.svelte';
import DiffOverlay from './components/DiffOverlay.svelte';
import Editor from './components/Editor.svelte';
import EditorToolbar from './components/EditorToolbar.svelte';
Expand Down Expand Up @@ -342,6 +343,12 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
let isAtBottom = $state(false);

let showHome = $state(false);
// Which side the tab column is on, or null while the tabs are across the
// title bar or hidden (#884). Zen mode clears `showTabs`, so it hides the
// column through the same switch it uses for the strip.
const tabColumnSide = $derived(
tabManager.tabs.length > 0 && settings.tabPlacement !== 'top' ? settings.tabPlacement : null,
);
let viewerWidth = $state(0);
// The bounds come from TOC_WIDTH_RANGE, the same object settings.setTocWidth
// clamps against, so the handle cannot offer a width persistence would shrink.
Expand Down Expand Up @@ -3949,21 +3956,6 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu

<Settings show={showSettings} theme={settings.theme} onSetTheme={(t) => (settings.theme = t)} onclose={() => (showSettings = false)} />

{#if activeExternalChangeConflict && !showHome}
<div class="external-change-bar" role="status">
<span class="external-change-text">{t('externalChange.message', settings.language)}</span>
<button class="external-change-action" onclick={compareExternalChange}>
{t('externalChange.compare', settings.language)}
</button>
<button class="external-change-action" onclick={resolveExternalChangeByReloading}>
{t('externalChange.reload', settings.language)}
</button>
<button class="external-change-action primary" onclick={resolveExternalChangeByKeepingBuffer}>
{t('externalChange.keepMine', settings.language)}
</button>
</div>
{/if}

<DiffOverlay
show={comparison !== null}
onDisk={comparison?.onDisk ?? ''}
Expand All @@ -3980,6 +3972,35 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
onreload={resolveExternalChangeByReloading}
onkeep={resolveExternalChangeByKeepingBuffer} />

{#if tabColumnSide}
<TabColumn side={tabColumnSide} {showHome} ontabclick={() => (showHome = false)} oncloseTab={closeTabAndWindowIfLast} />
{/if}

<!--
Plain block with the tabs across the top. With a tab column (#884) it
becomes the box the document is laid out in, inset by the column's width,
so the absolutely positioned layout, the outline and the home page all
measure from the column's edge without each learning about it.
-->
<div
class="content-area"
class:beside-tab-column={tabColumnSide !== null}
style:left={tabColumnSide === 'left' ? `${settings.tabColumnWidth}px` : null}
style:right={tabColumnSide === 'right' ? `${settings.tabColumnWidth}px` : null}>
{#if activeExternalChangeConflict && !showHome}
<div class="external-change-bar" role="status">
<span class="external-change-text">{t('externalChange.message', settings.language)}</span>
<button class="external-change-action" onclick={compareExternalChange}>
{t('externalChange.compare', settings.language)}
</button>
<button class="external-change-action" onclick={resolveExternalChangeByReloading}>
{t('externalChange.reload', settings.language)}
</button>
<button class="external-change-action primary" onclick={resolveExternalChangeByKeepingBuffer}>
{t('externalChange.keepMine', settings.language)}
</button>
</div>
{/if}
{#if tabManager.activeTab && !isHomePath(tabManager.activeTab.path) && !showHome}
<div
class="markdown-container"
Expand Down Expand Up @@ -4338,6 +4359,7 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
{:else}
<HomePage {recentFiles} {pinnedTags} onselectFile={selectFile} onloadFile={loadMarkdown} onremoveRecentFile={removeRecentFile} onnewFile={handleNewFile} onopenPinnedTag={openPinnedTag} onunpinTag={unpinTagFromHome} />
{/if}
</div>

<div
class="tooltip align-{tooltip.align} {tooltip.show ? 'visible' : ''}"
Expand Down Expand Up @@ -4853,6 +4875,16 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
}
}
/* Layout System */
/* Absolute, not fixed: with no z-index it opens no stacking context, so
menus inside it still stack against the title bar and the tab column. */
.content-area.beside-tab-column {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}

.layout-container {
display: flex;
width: 100%;
Expand Down Expand Up @@ -4966,9 +4998,10 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu
* `.layout-container` is absolutely positioned from top: 0, so a bar in
* normal flow would end up underneath it. Pinned just below the 36px
* title bar instead, the way editors surface file-changed-on-disk notices.
* Absolute inside `.content-area`, so it spans the document beside a tab column.
*/
.external-change-bar {
position: fixed;
position: absolute;
top: 36px;
left: 0;
right: 0;
Expand Down
12 changes: 12 additions & 0 deletions src/lib/components/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,18 @@
</label>
</div>

<div class="setting-item" class:inactive={!settings.showTabs}>
<label for="appearance-tab-position">{t('settings.tabPosition', settings.language)}</label>
<div class="select-wrapper">
<select id="appearance-tab-position" disabled={!settings.showTabs} bind:value={settings.tabPosition}>
<option value="top">{t('settings.tabPositionTop', settings.language)}</option>
<option value="left">{t('settings.tabPositionLeft', settings.language)}</option>
<option value="right">{t('settings.tabPositionRight', settings.language)}</option>
</select>
<svg class="select-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
</div>

<div class="setting-item" class:inactive={!settings.showTabs}>
<label for="appearance-duplicate-name-folder">{t('settings.showFolderForDuplicateNames', settings.language)}</label>
<label class="toggle">
Expand Down
13 changes: 12 additions & 1 deletion src/lib/components/Tab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { modifierFor, shortcutLabel } from '../utils/shortcuts.js';
import { windowDisplay, type ViewerWindowEntry } from '../utils/viewerWindows.js';

let { tab, folderSuffix, isActive, onclick, onclose } = $props<{
let { tab, folderSuffix, isActive, onclick, onclose, vertical = false } = $props<{
tab: Tab;
/**
* The containing folder, when another tab in this window holds a
Expand All @@ -23,6 +23,8 @@
isActive: boolean;
onclick: () => void;
onclose: (e: MouseEvent) => void;
/** Drawn as a full-width row of the tab column rather than a chip in the strip (#884). */
vertical?: boolean;
}>();

let tabContextMenu = $state<{
Expand Down Expand Up @@ -162,6 +164,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="tab {isActive ? 'active' : ''}"
class:vertical
role="group"
title={tabTooltip(tab)}
oncontextmenu={handleContextMenu}
Expand Down Expand Up @@ -217,6 +220,14 @@
color 0.25s cubic-bezier(0.05, 0.95, 0.05, 0.95);
}

.tab.vertical {
width: 100%;
min-width: 0;
max-width: none;
height: 30px;
box-sizing: border-box;
}

.tab:hover {
background-color: var(--color-neutral-muted);
}
Expand Down
Loading