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
16 changes: 12 additions & 4 deletions .github/workflows/dprint-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
#
# Implementation:
# - Mondays 09:00 UTC, run `npx dprint config update -y` which bumps
# every plugin in dprint.json to its latest version.
# every plugin in dprint.json to its newest version that is at least 7
# days old. dprint reads `min-release-age=7` from `.npmrc`, the same
# cooldown npm installs follow, so a bump can stop short of the latest
# release or skip a plugin; the step's log names each one held back (#958).
# - Apply any formatting changes from new plugin rules via `npx dprint fmt`.
# - If anything changed, open a PR via peter-evans/create-pull-request.
# - If nothing changed, exit clean (no PR, no spam).
Expand Down Expand Up @@ -52,8 +55,8 @@ jobs:
- name: Install dependencies
run: npm ci

# Bump every plugin in dprint.json to current latest.
# Output: deterministic diff to dprint.json. -y = non-interactive.
# Bump every plugin in dprint.json to its newest version older than
# the 7-day cooldown in `.npmrc`. -y = non-interactive.
- name: Bump dprint plugins
run: npx dprint config update -y

Expand All @@ -70,7 +73,7 @@ jobs:
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Every dprint plugin is already at its latest version."
echo "No dprint plugin has a newer version that is past the 7-day cooldown."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Files changed:"
Expand Down Expand Up @@ -111,6 +114,11 @@ jobs:

See #236 for the rationale and the schedule.

Each plugin moves to its newest version that is at least 7 days
old (`min-release-age` in `.npmrc`), so a plugin may stop short
of its latest release or not move at all. The rest follow in a
later run.

Auto-generated by `.github/workflows/dprint-update.yml`.

**No checks will appear on this PR.** GitHub does not run
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ without asking again.

- **"Release" means ship it, end to end.** Use the `release` skill. Don't stop
to ask which version or whether to go ahead.
- **Merge your own PRs into `dev`.** Once a PR you opened is green and
mergeable, squash-merge it without asking. The release PR from `dev` into
`main` is different: you merge it too, but only after the maintainer has
approved that release. Approval covers one release, not the next.
- **Write lasting instructions down.** When the maintainer sets a rule for how
to work, add it to `AGENTS.md` (any agent) or this file (Claude only) in the
same session. Put a procedure in a skill instead.
Expand Down
7 changes: 5 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,11 @@ These are settings, not files, so a PR cannot make them:
merges (for everything into `dev`).
- **Actions may create pull requests** (Settings → Actions → General). The
dprint bump already needs this.
- **If `main` has a branch ruleset, let GitHub Actions bypass it.** The bump
commit and the tag are pushed to `main` with `GITHUB_TOKEN`.
- **Let GitHub Actions bypass any ruleset or branch protection on `main` and
`dev`, and any tag protection matching `v*`.** `auto-release.yml` pushes
with `GITHUB_TOKEN`: the `bump` job pushes the bump commit and the `vX.Y.Z`
tag to `main`, and the `sync-dev` job pushes the merge of `main` back into
`dev`. Without a bypass, that job fails at its push.

### Pushes made with `GITHUB_TOKEN` start no workflows

Expand Down
2 changes: 1 addition & 1 deletion dprint.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"plugins": [
"npm:@dprint/[email protected]",
"npm:@dprint/[email protected]",
"npm:@dprint/markdown@0.26.0",
"npm:@dprint/markdown@0.28.2",
"npm:[email protected]",
"npm:@dprint/[email protected]"
]
Expand Down
11 changes: 3 additions & 8 deletions src/components/editor/SQLEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useSchemaCompletion } from "../../hooks/useSchemaCompletion";
import { statementRange } from "../../lib/error-marker";
import { monacoThemeData, monacoThemeName } from "../../lib/monaco-theme";
import { runConnectionId } from "../../lib/run-connection";
import { liveConnectionIds, runConnectionId } from "../../lib/run-connection";
import { createCompletionProvider } from "../../lib/schema-completion-provider";
import { formatSql } from "../../lib/sql-format";
import { sqlAtCursor } from "../../lib/statement-at-cursor";
Expand All @@ -13,7 +13,6 @@ import { setPendingContentFlush, useEditorStore } from "../../stores/editorStore
import { useResultStore } from "../../stores/resultStore";
import { useSchemaStore } from "../../stores/schemaStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { useSqliteStore } from "../../stores/sqliteStore";
import { useThemeStore } from "../../stores/themeStore";

/**
Expand All @@ -27,14 +26,10 @@ import { useThemeStore } from "../../stores/themeStore";
function activeTabContext(): { connectionId?: string; database?: string; session?: string } {
const { tabs, activeTabId } = useEditorStore.getState();
const tab = tabs.find((t) => t.id === activeTabId);
const { selectedConnectionId, activeConnections } = useConnectionStore.getState();
const live = [
...activeConnections.map((c) => c.id),
...useSqliteStore.getState().sessions.map((s) => s.id),
];
const { selectedConnectionId } = useConnectionStore.getState();
return {
// The server the tab is marked with, not whichever is selected (#788).
connectionId: runConnectionId(tab, selectedConnectionId, live),
connectionId: runConnectionId(tab, selectedConnectionId, liveConnectionIds()),
database: tab?.database,
session: tab?.type === "query" ? tab.id : undefined,
};
Expand Down
25 changes: 25 additions & 0 deletions src/components/layout/__tests__/useConnectionSwitching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,29 @@ describe("editor tab and selected connection agree (#788)", () => {
expect(activeTab()?.connectionId).toBe("sqlite-1");
expect(useEditorStore.getState().tabs.find((t) => t.id === "tab-0")?.connectionId).toBe("dev");
});

it("drops the old server's profile from a tab a SQLite file takes over (#962)", () => {
// The tab's server is gone, so the file takes the tab rather than a new one.
setConnections([], null);
const { result } = renderHook(() => useConnectionSwitching());
useSqliteStore.setState({ sessions: [{ id: "sqlite-1", path: "data.db" } as unknown as SqliteSession] });

act(() => result.current.attachSqliteSession("sqlite-1"));

expect(activeTab()?.id).toBe("tab-0");
expect(activeTab()?.connectionId).toBe("sqlite-1");
// Kept, it would reconnect p-dev and bind the tab back to it at the next start.
expect(activeTab()?.profileId).toBeUndefined();
});

it("replaces the old server's profile on a tab a new server takes over", () => {
setConnections([], null);
const { result } = renderHook(() => useConnectionSwitching());
setConnections([prod], "prod");

act(() => result.current.attachNewConnection(prod));

expect(activeTab()?.id).toBe("tab-0");
expect(activeTab()?.profileId).toBe("p-prod");
});
});
15 changes: 5 additions & 10 deletions src/components/layout/useConnectionSwitching.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { useEffect } from "react";
import { runConnectionId } from "../../lib/run-connection";
import { liveConnectionIds, runConnectionId } from "../../lib/run-connection";
import { useConnectionStore } from "../../stores/connectionStore";
import { useEditorStore } from "../../stores/editorStore";
import { useSqliteStore } from "../../stores/sqliteStore";
import type { ConnectionInfo } from "../../types";

/** Every connection that can run a statement right now. */
function liveConnectionIds(): string[] {
return [
...useConnectionStore.getState().activeConnections.map((c) => c.id),
...useSqliteStore.getState().sessions.map((s) => s.id),
];
}

/**
* Keep the selected connection and the active editor tab pointing at the
* same server (#788).
Expand Down Expand Up @@ -83,7 +75,10 @@ export function useConnectionSwitching() {
if (active && !activeHasServer) {
// Only a tab with no live server is taken over, so this does not
// normally move a session; through the guard all the same (#843).
requestTabConnection(active.id, connectionId, conn?.database ?? undefined, conn?.profile_id);
// A SQLite file has no profile. `null` drops the one the tab still has
// from the server it was on, which would otherwise send it back there
// at the next start (#962).
requestTabConnection(active.id, connectionId, conn?.database ?? undefined, conn?.profile_id ?? null);
return;
}
openTabFor(connectionId, conn);
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/useStartupReconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function useStartupReconnect() {
const { tabs, setTabConnection: setTabConn } = useEditorStore.getState();
const profileIds = [
...new Set(
tabs.filter((t) => t.profileId).map((t) => t.profileId!),
tabs.flatMap((t) => (t.profileId ? [t.profileId] : [])),
),
];

Expand Down
4 changes: 3 additions & 1 deletion src/components/schema/SchemaTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ export function SchemaTree({ connectionId }: { connectionId: string }) {
const selectDatabase = (dbName: string) => {
if (!activeTabId) return;
const conn = activeConnections.find((c) => c.id === connectionId);
requestTabConnection(activeTabId, connectionId, dbName, conn?.profile_id);
// `null` for a tree with no profile (a SQLite file), so the tab does not
// keep the profile of the server it is moved from (#962).
requestTabConnection(activeTabId, connectionId, dbName, conn?.profile_id ?? null);
};

// Tracks pending single-click timers keyed by "db.name"
Expand Down
51 changes: 51 additions & 0 deletions src/hooks/__tests__/useLiveConnectionIds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { liveConnectionIds } from "../../lib/run-connection";
import { useConnectionStore } from "../../stores/connectionStore";
import { type SqliteSession, useSqliteStore } from "../../stores/sqliteStore";
import type { ConnectionInfo } from "../../types";
import { useLiveConnectionIds } from "../useLiveConnectionIds";

vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));

const server = (id: string) => ({ id }) as unknown as ConnectionInfo;
const file = (id: string) => ({ id }) as unknown as SqliteSession;

beforeEach(() => {
useConnectionStore.setState({ activeConnections: [] });
useSqliteStore.setState({ sessions: [] });
});

describe("useLiveConnectionIds", () => {
it("lists connected servers and open SQLite files", () => {
useConnectionStore.setState({ activeConnections: [server("dev")] });
useSqliteStore.setState({ sessions: [file("sqlite-1")] });

const { result } = renderHook(() => useLiveConnectionIds());

expect(result.current).toEqual(["dev", "sqlite-1"]);
});

it("follows servers and files as they come and go", () => {
const { result } = renderHook(() => useLiveConnectionIds());
expect(result.current).toEqual([]);

act(() => useConnectionStore.setState({ activeConnections: [server("dev"), server("prod")] }));
act(() => useSqliteStore.setState({ sessions: [file("sqlite-1")] }));
expect(result.current).toEqual(["dev", "prod", "sqlite-1"]);

act(() => useConnectionStore.setState({ activeConnections: [server("prod")] }));
expect(result.current).toEqual(["prod", "sqlite-1"]);
});

// Where a run goes and whether a tab's connection still counts (#944) read
// one list each; they must not drift apart.
it("is the same list as liveConnectionIds", () => {
useConnectionStore.setState({ activeConnections: [server("dev")] });
useSqliteStore.setState({ sessions: [file("sqlite-1"), file("sqlite-2")] });

const { result } = renderHook(() => useLiveConnectionIds());

expect(result.current).toEqual(liveConnectionIds());
});
});
22 changes: 22 additions & 0 deletions src/hooks/useLiveConnectionIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useMemo } from "react";
import { useConnectionStore } from "../stores/connectionStore";
import { useSqliteStore } from "../stores/sqliteStore";

/**
* Every connection that can run a statement right now, for a component: the
* connected servers and the open SQLite files.
*
* `liveConnectionIds` (lib/run-connection) is the same list read once, for
* code outside a component. This one re-renders its caller when a server
* connects or disconnects or a file opens or closes. Both have to stay the
* same list: where a run goes and whether a tab's connection still counts
* (#944) depend on it.
*/
export function useLiveConnectionIds(): string[] {
const activeConnections = useConnectionStore((s) => s.activeConnections);
const sqliteSessions = useSqliteStore((s) => s.sessions);
return useMemo(
() => [...activeConnections.map((c) => c.id), ...sqliteSessions.map((s) => s.id)],
[activeConnections, sqliteSessions],
);
}
10 changes: 3 additions & 7 deletions src/hooks/useQueryExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { runConnectionId } from "../lib/run-connection";
import { useConnectionStore } from "../stores/connectionStore";
import { useEditorStore } from "../stores/editorStore";
import { useResultStore } from "../stores/resultStore";
import { useSqliteStore } from "../stores/sqliteStore";
import { useLiveConnectionIds } from "./useLiveConnectionIds";

/**
* The editor toolbar's execution context, pre-bound.
Expand All @@ -27,8 +27,7 @@ export function useQueryExecution() {
const selectedConnectionId = useConnectionStore(
(s) => s.selectedConnectionId,
);
const activeConnections = useConnectionStore((s) => s.activeConnections);
const sqliteSessions = useSqliteStore((s) => s.sessions);
const liveConnectionIds = useLiveConnectionIds();
const activeTabId = useEditorStore((s) => s.activeTabId);
const tabs = useEditorStore((s) => s.tabs);
const activeTab = tabs.find((t) => t.id === activeTabId);
Expand All @@ -41,10 +40,7 @@ export function useQueryExecution() {
const isExecuting = useResultStore((s) => s.isExecuting);

// The server the tab is marked with, not whichever is selected (#788).
const connectionId = runConnectionId(activeTab, selectedConnectionId, [
...activeConnections.map((c) => c.id),
...sqliteSessions.map((s) => s.id),
]);
const connectionId = runConnectionId(activeTab, selectedConnectionId, liveConnectionIds);
const database = activeTab?.database;
// The tab's own server session, so what one run sets up is there for the
// next (#731). Only a query tab has one; the others are views of an object.
Expand Down
10 changes: 3 additions & 7 deletions src/hooks/useResultsFollowActiveTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { runConnectionId } from "../lib/run-connection";
import { useConnectionStore } from "../stores/connectionStore";
import { useEditorStore } from "../stores/editorStore";
import { useResultStore } from "../stores/resultStore";
import { useSqliteStore } from "../stores/sqliteStore";
import { useLiveConnectionIds } from "./useLiveConnectionIds";

/**
* Keep a grid read on one server from being shown under another (#842).
Expand All @@ -19,16 +19,12 @@ export function useResultsFollowActiveTab() {
const activeTabId = useEditorStore((s) => s.activeTabId);
const tabs = useEditorStore((s) => s.tabs);
const selectedConnectionId = useConnectionStore((s) => s.selectedConnectionId);
const activeConnections = useConnectionStore((s) => s.activeConnections);
const sqliteSessions = useSqliteStore((s) => s.sessions);
const liveConnectionIds = useLiveConnectionIds();
const dropForeignResults = useResultStore((s) => s.dropForeignResults);

const activeTab = tabs.find((t) => t.id === activeTabId);
// The tab's own server, as Run would pick it (#788).
const tabConnectionId = runConnectionId(activeTab, selectedConnectionId, [
...activeConnections.map((c) => c.id),
...sqliteSessions.map((s) => s.id),
]);
const tabConnectionId = runConnectionId(activeTab, selectedConnectionId, liveConnectionIds);

useEffect(() => {
dropForeignResults(tabConnectionId);
Expand Down
24 changes: 21 additions & 3 deletions src/lib/run-connection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { useConnectionStore } from "../stores/connectionStore";
import { useSqliteStore } from "../stores/sqliteStore";
import type { EditorTab } from "../types";

/**
* Every connection that can run a statement right now: the connected servers
* and the open SQLite files.
*
* The one definition of "live" for code outside a component. Where a run goes
* (`runConnectionId`) and whether a tab's own connection still counts
* (`resultStore`, #944) have to agree on it, or a tab is left bound to a
* connection Run no longer uses.
*/
export function liveConnectionIds(): string[] {
return [
...useConnectionStore.getState().activeConnections.map((c) => c.id),
...useSqliteStore.getState().sessions.map((s) => s.id),
];
}

/**
* Which connection Run sends the active tab's statements to.
*
Expand All @@ -8,9 +26,9 @@ import type { EditorTab } from "../types";
* selected connection instead, so a tab marked as production could run on
* dev and the reverse (#788). The selected connection is the answer only for a
* tab that has no live connection of its own: a fresh tab, or one whose
* server has gone — and such a tab carries no server markers either. A fresh
* tab is bound to where its first run went (`resultStore`, #942), and from
* then on runs there like any other.
* server has gone — and such a tab carries no server markers either. Either
* is bound to where that run went (`resultStore`, #942, #944), and from then
* on runs there like any other.
*/
export function runConnectionId(
tab: Pick<EditorTab, "connectionId"> | undefined,
Expand Down
7 changes: 4 additions & 3 deletions src/lib/transaction-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
*
* The backend reports the session's state on a run's last result (#916), or
* on the error of a run that failed (#917), and the editor takes that when it
* is there. It is not always: MySQL has no `@@in_transaction` to read, and the
* server may not say. Then the state is read off the statements themselves. That
* is a best guess, and errs towards "open": the indicator and the prompts it
* is there. It asks MySQL and MariaDB alike, so it is missing only when the
* probe could not get an answer: the session could not be reached, or the
* probe failed or timed out. Then the state is read off the statements
* themselves. That is a best guess, and errs towards "open": the indicator and the prompts it
* drives cost a click when wrong, while missing a transaction rolls back the
* user's work without a word.
*/
Expand Down
Loading
Loading