Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to LowQuery are documented here. The format follows [Keep a

## [Unreleased]

### Added
- A title bar in the LowBloat style on Windows and Linux, with File, Query, View and Help menus and themed window buttons. macOS keeps its traffic lights and gets the same menus in the menu bar. Alt or F10 moves to the menus; Help → Check for updates reports when LowQuery is up to date ([#20](https://github.com/LowBloat/LowQuery/issues/20)).

### Fixed
- The window no longer flashes white or jumps in size on startup: it stays hidden until the interface has painted ([#20](https://github.com/LowBloat/LowQuery/issues/20)).

## [0.5.2] - 2026-10-23

### Added
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ LowQuery is a Tauri 2 desktop app. The interface is Svelte 5 with the [CodeAtlas
| File | Responsibility |
| --- | --- |
| `lib.rs` | Tauri commands, session registry, password resolution |
| `window.rs` | The main window starts hidden and is shown once the page has painted (`window_painted`), with a five-second fallback; `tauri.macos.conf.json` keeps the native traffic lights |
| `profiles.rs` | `connections.json` in the user config folder; passwords in the keychain (`keyring`) |
| `model.rs` | Serializable types shared with the interface |
| `error.rs` | `AppError` with kind, native code, position, detail and hint |
Expand Down Expand Up @@ -106,6 +107,7 @@ LowQuery is a Tauri 2 desktop app. The interface is Svelte 5 with the [CodeAtlas
| `lib/state/importer.svelte.ts` | Import dialog: detected tool files, preview, duplicate check |
| `lib/state/tns.svelte.ts` | Detected `tnsnames.ora` sources and aliases for the connection and import dialogs |
| `lib/components/` | Editor, grid, results, sidebar, dialogs |
| `lib/app-menu.ts`, `lib/components/TitleBar.svelte`, `lib/state/window-frame.svelte.ts` | Title bar and File/Query/View/Help menus (in the title bar on Windows and Linux, in the menu bar on macOS) |
| `lib/dev-mock.ts` | Development-only backend for `bun run dev` in a browser |

## Object DDL and tree actions
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@
],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-start-dragging",
"dialog:allow-save",
"dialog:allow-open",
"updater:default",
"process:allow-restart",
"process:allow-exit",
"opener:default"
]
}
7 changes: 7 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod model;
mod profiles;
mod transfer;
mod updates;
mod window;

use std::collections::HashMap;
use std::sync::Arc;
Expand Down Expand Up @@ -435,6 +436,11 @@ pub fn run() {
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.manage(update_policy)
.manage(window::FirstShow::default())
.setup(|app| {
window::start_fallback(app.handle());
Ok(())
})
.manage(AppState {
profiles,
sessions: RwLock::new(HashMap::new()),
Expand Down Expand Up @@ -473,6 +479,7 @@ pub fn run() {
ai_activity,
ai_activity_clear,
updates::update_policy,
window::window_painted,
])
.run(tauri::generate_context!())
.expect("error while starting LowQuery");
Expand Down
47 changes: 47 additions & 0 deletions src-tauri/src/window.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! The main window starts hidden (`visible: false` in tauri.conf.json), so the
//! webview's white background never shows while the page loads. The page
//! shows it once it has painted.

use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use tauri::{AppHandle, Manager, Runtime};

#[derive(Default)]
pub struct FirstShow(AtomicBool);

/// How long the window waits for the page before it is shown anyway, so a
/// page that fails to load never leaves an invisible app.
const FIRST_SHOW_FALLBACK: Duration = Duration::from_secs(5);

fn show_first_time<R: Runtime>(app: &AppHandle<R>) {
if app.state::<FirstShow>().0.swap(true, Ordering::SeqCst) {
return;
}
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}

/// Shows the window after `FIRST_SHOW_FALLBACK` if the page has not.
pub fn start_fallback<R: Runtime>(app: &AppHandle<R>) {
let app = app.clone();
std::thread::spawn(move || {
std::thread::sleep(FIRST_SHOW_FALLBACK);
show_first_time(&app);
});
}

/// Called by the page once it has painted and whenever the theme changes: the
/// native window takes the page background (`#rrggbb`), so resizing never
/// uncovers white, and appears the first time.
#[tauri::command]
pub fn window_painted(background: String, app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if let Ok(color) = background.parse::<tauri::window::Color>() {
let _ = window.set_background_color(Some(color));
}
}
show_first_time(&app);
}
4 changes: 3 additions & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
"height": 800,
"minWidth": 720,
"minHeight": 480,
"maximized": true
"maximized": true,
"visible": false,
"decorations": false
}
],
"security": {
Expand Down
19 changes: 19 additions & 0 deletions src-tauri/tauri.macos.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"title": "LowQuery",
"width": 1280,
"height": 800,
"minWidth": 720,
"minHeight": 480,
"maximized": true,
"visible": false,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}
]
}
}
96 changes: 94 additions & 2 deletions src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<script lang="ts">
import { onMount } from "svelte";
import { Drawer, Toaster } from "@codeatlasdev/ui";
import { Drawer, Toaster, colorScheme, toast } from "@codeatlasdev/ui";
import { getVersion } from "@tauri-apps/api/app";
import { openUrl } from "@tauri-apps/plugin-opener";
import { exit } from "@tauri-apps/plugin-process";
import TitleBar from "./lib/components/TitleBar.svelte";
import { IS_MAC, appMenu, setNativeAppMenu } from "./lib/app-menu";
import { windowFrame } from "./lib/state/window-frame.svelte";
import { importer } from "./lib/state/importer.svelte";
import { ai } from "./lib/state/ai.svelte";
import { transfer } from "./lib/state/transfer.svelte";
import { emptyProfile } from "./lib/api";
import Sidebar from "./lib/components/Sidebar.svelte";
import TabBar from "./lib/components/TabBar.svelte";
import QueryView from "./lib/components/QueryView.svelte";
Expand Down Expand Up @@ -58,6 +68,78 @@
target.addEventListener("pointerup", up);
}

const REPO = "https://github.com/LowBloat/LowQuery";
const SEPARATOR = { separator: true } as const;

async function checkForUpdates() {
await updates.check(true);
if (updates.error) toast.error(`Could not check for updates: ${updates.error}`);
else if (!updates.available) toast.success("LowQuery is up to date.");
}

async function showAbout() {
const version = await getVersion().catch(() => "");
toast.info(version ? `LowQuery ${version} · AGPL-3.0-only` : "LowQuery · AGPL-3.0-only");
}

// Application menus: in the title bar on Windows and Linux, in the global
// menu bar on macOS.
const menus = $derived.by(() => {
const tab = workspace.activeTab;
const profile = workspace.profile(tab?.profileId ?? null);
const connected = !!tab?.session;
return [
appMenu("file", "File", [
{ label: "New query", shortcut: "Ctrl+T", action: () => workspace.newTab(tab?.profileId ?? null) },
{ label: "New connection…", action: () => (workspace.editing = emptyProfile()) },
{ label: "Import connections…", action: () => importer.show() },
SEPARATOR,
{ label: "Run SQL file…", disabled: !profile, action: () => profile && transfer.chooseSqlFile(profile, null) },
SEPARATOR,
{ label: "Close tab", shortcut: "Ctrl+W", disabled: !tab, action: () => tab && workspace.closeTab(tab.id) },
!IS_MAC && SEPARATOR,
!IS_MAC && { label: "Quit LowQuery", action: () => exit(0) },
]),
appMenu("query", "Query", [
{ label: "Run statement", shortcut: "Ctrl+Enter", disabled: !tab || !profile || tab.running, action: () => tab && workspace.run(tab, "statement") },
{ label: "Run script", shortcut: "F5", disabled: !tab || !profile || tab.running, action: () => tab && workspace.run(tab, "script") },
{ label: "Stop", disabled: !tab?.running, action: () => tab && workspace.cancel(tab) },
SEPARATOR,
{ label: "Commit", disabled: !tab?.inTransaction, action: () => tab && workspace.endTransaction(tab, true) },
{ label: "Rollback", disabled: !tab?.inTransaction, action: () => tab && workspace.endTransaction(tab, false) },
{ label: "Auto-commit", checked: tab?.autocommit ?? true, disabled: !connected, action: () => tab && workspace.setAutocommit(tab, !tab.autocommit) },
]),
appMenu("view", "View", [
{ label: "Dark theme", checked: colorScheme.isDark, action: () => colorScheme.toggle() },
SEPARATOR,
{ label: "AI agents…", action: () => ai.show() },
]),
appMenu("help", "Help", [
{ label: "Documentation", action: () => openUrl(`${REPO}#readme`) },
{ label: "Release notes", action: () => openUrl(`${REPO}/releases`) },
{ label: "Report a problem", action: () => openUrl(`${REPO}/issues/new/choose`) },
SEPARATOR,
{ label: "Check for updates…", action: () => checkForUpdates() },
!IS_MAC && SEPARATOR,
!IS_MAC && { label: "About LowQuery", action: () => showAbout() },
]),
];
});

const activeProfile = $derived(workspace.profile(workspace.activeTab?.profileId ?? null));

$effect(() => {
if (!IS_MAC) return;
void setNativeAppMenu(menus, { label: "About LowQuery", action: () => showAbout() }).catch((e) => console.error("Failed to set the menu bar:", e));
});

// The window starts hidden; it appears once the page has painted, and
// follows the theme's background so resizing never shows white.
$effect(() => {
void colorScheme.isDark;
windowFrame.painted();
});

function onkeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
// Reloading the page would discard open sessions and transactions.
Expand Down Expand Up @@ -92,6 +174,8 @@

<svelte:window {onkeydown} />

<div class="lq-shell">
<TitleBar {menus} title={workspace.activeTab?.title ?? "LowQuery"} detail={activeProfile?.name ?? ""} />
<div class="lq-app" class:lq-app--narrow={narrow} style="--side-w:{sideWidth}px">
{#if !narrow}
<aside class="lq-app__side">
Expand Down Expand Up @@ -120,6 +204,7 @@
{/each}
</main>
</div>
</div>

{#if narrow}
<Drawer bind:open={drawer} side="left" title="Connections" hideTitle width="min(88vw, 340px)" class="lq-drawer">
Expand All @@ -142,10 +227,17 @@
<Toaster />

<style>
.lq-shell {
display: flex;
flex-direction: column;
height: var(--ca-vh, 100vh);
overflow: hidden;
}
.lq-app {
display: grid;
grid-template-columns: var(--side-w) 4px minmax(0, 1fr);
height: var(--ca-vh, 100vh);
flex: 1;
min-height: 0;
overflow: hidden;
}
.lq-app--narrow {
Expand Down
30 changes: 30 additions & 0 deletions src/lib/app-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test";
import { accelerator, appMenu, shortcutLabel } from "./app-menu";
import base from "../../src-tauri/tauri.conf.json";
import mac from "../../src-tauri/tauri.macos.conf.json";

const item = (label: string) => ({ label, action: () => {} });
const separator = { separator: true } as const;

test("menus drop omitted entries and stray separators", () => {
const menu = appMenu("file", "File", [separator, item("New query"), false, separator, null, separator, item("Quit"), separator]);
expect(menu.entries.map((entry) => ("label" in entry ? entry.label : "-"))).toEqual(["New query", "-", "Quit"]);
});

test("shortcuts follow the platform", () => {
expect(shortcutLabel("Ctrl+Enter", false)).toBe("Ctrl+Enter");
expect(shortcutLabel("Ctrl+Shift+Enter", true)).toBe("⌘⇧Enter");
expect(accelerator("Ctrl+T")).toBe("CmdOrCtrl+T");
expect(accelerator("F5")).toBe("F5");
});

// tauri.macos.conf.json replaces the whole windows array (JSON merge patch),
// so it must repeat every other window setting.
test("the macOS window differs only in its title bar", () => {
const { decorations, titleBarStyle, hiddenTitle, ...macWindow } = mac.app.windows[0];
const { decorations: baseDecorations, ...baseWindow } = base.app.windows[0];
expect(macWindow).toEqual(baseWindow);
expect(baseDecorations).toBe(false);
expect(base.app.windows[0].visible).toBe(false);
expect({ decorations, titleBarStyle, hiddenTitle }).toEqual({ decorations: true, titleBarStyle: "Overlay", hiddenTitle: true });
});
Loading
Loading