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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## 2026-11-03

- New users now get a welcome dialog on first launch (web and desktop) explaining how to connect and what to try; reopen it from Help ▸ Welcome… (2026-11-03)
- File ▸ Show crash log opens the folder with dbscope.log for bug reports (desktop) (2026-11-03)
- dbscope_pg_proxy can be pinned to one database with --allow-db-host, and caps each result at --max-rows rows (default 100000, flagged truncated) (2026-11-03)
- Releases and web deploys run analyze and the tests on exactly the commit they ship (2026-11-03)
Expand Down
30 changes: 29 additions & 1 deletion lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ class DBScopeApp extends StatefulWidget {
required this.backend,
this.windowId = 0,
this.launchPayload = '',
this.showWelcome = false,
});
final BackendClient backend;
final int windowId;
final String launchPayload;

/// Open the welcome dialog once the first frame is up. `main.dart`
/// decides this for the host window with `shouldShowWelcome`.
final bool showWelcome;

@override
State<DBScopeApp> createState() => _DBScopeAppState();
}
Expand Down Expand Up @@ -110,7 +115,7 @@ class _DBScopeAppState extends State<DBScopeApp> {
theme: _theme,
darkTheme: _theme,
themeMode: _theme.brightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light,
home: const _Shell(),
home: widget.showWelcome ? const _WelcomeOnStartup(child: _Shell()) : const _Shell(),
builder: (context, child) {
return Stack(
children: [
Expand All @@ -127,6 +132,29 @@ class _DBScopeAppState extends State<DBScopeApp> {
}
}

/// Opens the welcome dialog over [child] after the first frame, once.
/// It sits under `MaterialApp.home` because the dialog needs a Navigator.
class _WelcomeOnStartup extends StatefulWidget {
const _WelcomeOnStartup({required this.child});
final Widget child;

@override
State<_WelcomeOnStartup> createState() => _WelcomeOnStartupState();
}

class _WelcomeOnStartupState extends State<_WelcomeOnStartup> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(showWelcome(context));
});
}

@override
Widget build(BuildContext context) => widget.child;
}

class _Shell extends StatelessWidget {
const _Shell();

Expand Down
6 changes: 6 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import 'rpc/isolate_transport.dart';
import 'server/host.dart';
import 'shell/app_state.dart';
import 'shell/ui_prefs.dart';
import 'shell/welcome_dialog.dart';
import 'update/update_service.dart';
import 'util/error_reporting.dart';
import 'util/shell_env.dart';
Expand Down Expand Up @@ -177,6 +178,11 @@ Future<void> main(List<String> args) async {
runApp(
DBScopeApp(
backend: host.localClient,
showWelcome: shouldShowWelcome(
seen: UiPrefs.instance.welcomeSeen,
hasSavedProfiles: ConnectionRegistry.instance.profiles.isNotEmpty,
autoConnecting: cliProfile != null,
),
),
);
}
Expand Down
23 changes: 23 additions & 0 deletions lib/shell/menu_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import 'env_dialog.dart';
import 'menu_spec.dart';
import 'shell_listenable.dart';
import 'theme_registry.dart';
import 'ui_prefs.dart';
import 'welcome_dialog.dart';
import 'window_actions.dart';

// One AppMenuBar widget; two renderers. The data lives in
Expand Down Expand Up @@ -312,6 +314,16 @@ class _MenuActions {
..._contributionsFor('AI'),
],
),
MenuSpec(
label: 'Help',
items: [
MenuItemSpec(
label: 'Welcome…',
onSelected: () => showWelcome(context),
),
..._contributionsFor('Help'),
],
),
];
// Plugin-defined top-level menus appear after the built-ins.
for (final label in MenuContributionRegistry.instance.extraMenuLabels) {
Expand Down Expand Up @@ -490,6 +502,17 @@ class _MenuButton extends StatelessWidget {
}
}

/// Shows the welcome dialog, remembers that it was seen so it stops
/// opening at startup, and starts the new-connection flow if the user
/// asked for it. Used at first launch and by Help ▸ Welcome….
Future<void> showWelcome(BuildContext context) async {
final choice = await showWelcomeDialog(context);
UiPrefs.instance.setWelcomeSeen();
if (choice != WelcomeChoice.addConnection) return;
if (!context.mounted) return;
await _addConnection(context);
}

Future<void> _addConnection(BuildContext context) async {
final profile = await showAddConnectionDialog(context);
if (profile == null) return;
Expand Down
11 changes: 9 additions & 2 deletions lib/shell/ui_prefs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart';

/// Persists per-machine preferences across launches: sidebar width and
/// side, inspector width and collapsed state, theme, SQL editor height,
/// row height, the last connection, the open tabs, and the shell env
/// file path.
/// row height, the last connection, the open tabs, the shell env file
/// path, and whether the welcome dialog was seen.
///
/// Each setter is fire-and-forget — callers don't await. SharedPreferences
/// is process-global, so a subwindow's read still sees the host's write
Expand All @@ -27,6 +27,7 @@ class UiPrefs {
static const _kLastConnectionId = 'ui.lastConnectionId';
static const _kOpenTabs = 'ui.openTabs';
static const _kEnvFilePath = 'env.filePath';
static const _kWelcomeSeen = 'ui.welcomeSeen';
static const _kMetaTrustPrefix = 'meta.trust.';

SharedPreferences? _prefs;
Expand Down Expand Up @@ -83,6 +84,12 @@ class UiPrefs {

void setEnvFilePath(String? v) => _setOrRemove(_kEnvFilePath, v);

/// Whether the welcome dialog was closed once. Null when preferences
/// can't be read, which `shouldShowWelcome` treats as "not seen".
bool? get welcomeSeen => _prefs?.getBool(_kWelcomeSeen);

void setWelcomeSeen() => unawaited(_prefs?.setBool(_kWelcomeSeen, true));

/// Pinned action-set hashes for a profile's metadata actions (JSON
/// `{"read": "<sha256>", "exec": "<sha256>"}`). Machine-local on purpose:
/// trust never travels with a shared profile or connection link. Null in
Expand Down
207 changes: 207 additions & 0 deletions lib/shell/welcome_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import 'dart:async';

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

import '../update/update_source.dart';

/// Whether the welcome dialog opens by itself at startup.
///
/// It is for someone who has never used dbscope, so it stays away from
/// anyone who clearly has: it was dismissed before, connections are
/// already saved, or a command-line launch is connecting on its own.
/// [seen] is null when preferences can't be read (a private browser
/// window); that counts as "not seen", because the only cost is showing
/// a dismissible dialog again to someone with no saved connections.
bool shouldShowWelcome({
required bool? seen,
required bool hasSavedProfiles,
required bool autoConnecting,
}) {
if (seen == true) return false;
if (hasSavedProfiles) return false;
if (autoConnecting) return false;
return true;
}

/// What the user picked to leave the welcome dialog.
enum WelcomeChoice {
/// Open the new-connection flow.
addConnection,

/// Just close the dialog.
close,
}

/// The first thing a new user sees: what dbscope is, how to connect,
/// and what to try once connected.
///
/// Returns null when dismissed with Escape or the barrier, which the
/// caller treats the same as [WelcomeChoice.close].
Future<WelcomeChoice?> showWelcomeDialog(BuildContext context) {
return showDialog<WelcomeChoice>(
context: context,
builder: (_) => const WelcomeDialog(),
);
}

class WelcomeDialog extends StatelessWidget {
const WelcomeDialog({super.key});

static final Uri _proxyGuide = Uri.parse(
'https://github.com/${ReleaseSource.owner}/${ReleaseSource.repo}'
'/tree/main/packages/dbscope_pg_proxy#readme',
);

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75);
return AlertDialog(
title: Column(
children: [
ClipRRect(
// Matches the rounded corners baked into the icon (rx 230/1024).
borderRadius: BorderRadius.circular(16.0),
child: Image.asset(
'assets/icon/dbscope_icon.png',
width: 72.0,
height: 72.0,
cacheWidth: 216,
semanticLabel: 'dbscope logo',
),
),
const SizedBox(height: 14.0),
const Text('Welcome to dbscope', textAlign: TextAlign.center),
],
),
content: SizedBox(
width: 500.0,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Browse, edit and query your databases, with an AI '
'assistant that writes the SQL for you. Nothing is open '
'yet, so here is how to get started.',
),
const SizedBox(height: 18.0),
const _Step(
number: 1,
title: 'Connect to a database',
body: kIsWeb
? 'A browser can’t talk to Postgres directly, so the web '
'version connects through dbscope_pg_proxy, a small '
'proxy you run next to your database. Start it, then '
'choose Add a connection and enter its address '
'(for example ws://127.0.0.1:8765).'
: 'Choose Add a connection for Postgres, Cloud SQL, or '
'SQLite inside an app on a USB-connected phone. To '
'open a local SQLite file, use File ▸ Open .db file….',
),
const _Step(
number: 2,
title: 'Explore your tables',
body:
'Tables appear in the sidebar. Click one to open it in '
'a tab, and double-click a cell to edit it. View ▸ '
'Schema draws every table and how they relate.',
),
const _Step(
number: 3,
title: 'Ask questions',
body:
'File ▸ New query opens a SQL editor. AI ▸ New AI query '
'lets you describe what you want in plain English; add '
'an API key under AI ▸ Settings… first.',
),
if (kIsWeb) ...[
const SizedBox(height: 4.0),
Wrap(
spacing: 8.0,
children: [
TextButton.icon(
icon: const Icon(Icons.menu_book_outlined, size: 16.0),
label: const Text('How to run the proxy'),
onPressed: () => unawaited(launchUrl(_proxyGuide)),
),
TextButton.icon(
icon: const Icon(Icons.laptop_mac_outlined, size: 16.0),
label: const Text('Get the Mac app'),
onPressed: () => unawaited(launchUrl(ReleaseSource.releasesPage)),
),
],
),
],
const SizedBox(height: 8.0),
Text(
'You can open this again from Help ▸ Welcome….',
style: TextStyle(fontSize: 12.0, color: muted),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(WelcomeChoice.close),
child: const Text('Not now'),
),
FilledButton(
autofocus: true,
onPressed: () => Navigator.of(context).pop(WelcomeChoice.addConnection),
child: const Text('Add a connection…'),
),
],
);
}
}

class _Step extends StatelessWidget {
const _Step({required this.number, required this.title, required this.body});

final int number;
final String title;
final String body;

@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 14.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24.0,
height: 24.0,
alignment: Alignment.center,
decoration: BoxDecoration(color: scheme.primary, shape: BoxShape.circle),
child: Text(
'$number',
style: TextStyle(
color: scheme.onPrimary,
fontSize: 12.0,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 2.0),
Text(body),
],
),
),
],
),
);
}
}
3 changes: 3 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,6 @@ dev_dependencies:

flutter:
uses-material-design: true
assets:
# The app icon, shown at the top of the welcome dialog.
- assets/icon/dbscope_icon.png
Loading
Loading