redesign welcome as full-screen overlay with recent projects

Welcome screen is now a full-window overlay that covers the IDE
when no project is open. Two-column layout: START actions (open
folder, clone from git, start Claude session) with keyboard
shortcut hints, and RECENT projects list showing name, path,
branch, and relative timestamp. Status line shows version, daemon
connection state, and active theme.

ProjectManager tracks up to 10 recent projects with path, branch
name, and last-opened time, persisted as JSON in app.recentProjects.
Boot chain: try last project → try cwd → show welcome. Clicking a
recent project opens it directly into Claude.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 23:35:21 +02:00
co-authored by Claude Opus 4.6
parent eedeaec05a
commit 16ecf51091
5 changed files with 361 additions and 134 deletions
+9 -4
View File
@@ -65,10 +65,15 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
- Graph view in context panel — lists files with inbound/outbound
link counts from `pql search --connections` (T-039).
- Last-opened project persisted to user settings and restored on
boot. First launch shows the welcome screen with a path-entry
dialog; subsequent launches re-open the last project directly
into Claude.
- Welcome screen redesigned as full-screen overlay with two-column
layout: START actions (open folder, clone, Claude session) with
keyboard shortcuts, and RECENT projects list showing path, branch,
and relative timestamps. Status line shows version, daemon
connection, and active theme.
- Recent projects history persisted to user settings (up to 10
entries with path, branch, and last-opened timestamp). Last
project auto-restored on boot; falls back to cwd, then welcome.
### Changed
+25 -10
View File
@@ -1,3 +1,4 @@
import 'package:clide_app/builtin/welcome/src/welcome_view.dart';
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
@@ -85,6 +86,7 @@ class _RootShellState extends State<_RootShell> {
children: [
const Positioned.fill(child: RootLayout()),
const ClidePalette(),
const Positioned.fill(child: _WelcomeOverlay()),
],
),
),
@@ -324,26 +326,19 @@ class _WorkspaceSlot extends StatelessWidget {
static const _editorTabId = 'editor.active';
static const _claudeTabId = 'claude.primary';
static const _welcomeTabId = 'welcome.view';
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: Listenable.merge([kernel.arrangement, kernel.project]),
listenable: kernel.arrangement,
builder: (ctx, _) {
final editorOpen = kernel.arrangement.editorOpen;
final editorTab = tabs.where((t) => t.id == _editorTabId).firstOrNull;
final TabContribution primary;
if (kernel.project.isOpen) {
final claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull;
primary = claude ?? active;
} else {
final welcome = tabs.where((t) => t.id == _welcomeTabId).firstOrNull;
primary = welcome ?? active;
}
final claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull;
final primary = claude ?? active;
if (!editorOpen || editorTab == null) {
return Container(color: tokens.panelBackground, child: primary.build(ctx));
@@ -491,3 +486,23 @@ class StatusbarHost extends StatelessWidget {
);
}
}
class _WelcomeOverlay extends StatelessWidget {
const _WelcomeOverlay();
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
return ListenableBuilder(
listenable: kernel.project,
builder: (ctx, _) {
if (kernel.project.isOpen) return const SizedBox.shrink();
final tokens = ClideTheme.of(ctx).surface;
return ColoredBox(
color: tokens.globalBackground,
child: const WelcomeView(),
);
},
);
}
}
+249 -109
View File
@@ -7,63 +7,97 @@ import 'package:flutter/widgets.dart';
class WelcomeView extends StatelessWidget {
const WelcomeView({super.key});
static const _ns = 'builtin.welcome';
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.i18n,
builder: (ctx, _) {
final i = kernel.i18n;
return ClideSurface(
color: tokens.globalBackground,
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 64, vertical: 48),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(flex: 1),
_Header(tokens: tokens),
const SizedBox(height: 48),
Expanded(
flex: 3,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
i.string('title', namespace: _ns, placeholder: 'clide'),
fontSize: 48,
fontWeight: FontWeight.w300,
color: tokens.globalForeground,
),
const SizedBox(height: 8),
ClideText(
i.string(
'subtitle',
namespace: _ns,
placeholder: 'Flutter desktop IDE for Claude Code',
),
muted: true,
),
const SizedBox(height: 40),
ClideText(
'START',
fontSize: clideFontCaption,
color: tokens.sidebarSectionHeader,
fontFamily: clideMonoFamily,
),
const SizedBox(height: 12),
_StartAction(
label: i.string('open-project', namespace: _ns, placeholder: 'Open project…'),
hint: i.string('open-project.hint', namespace: _ns, placeholder: 'Pick a git repository'),
icon: PhosphorIcons.folder,
onTap: () => _openProject(context),
),
SizedBox(width: 340, child: _StartColumn(tokens: tokens, kernel: kernel)),
const SizedBox(width: 48),
Expanded(child: _RecentColumn(tokens: tokens, kernel: kernel)),
],
),
),
);
},
_StatusLine(tokens: tokens, kernel: kernel),
],
),
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.tokens});
final SurfaceTokens tokens;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset('assets/logo/clide-logo-192.png', width: 64, height: 64),
const SizedBox(width: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('clide', fontSize: 42, fontWeight: FontWeight.w300, color: tokens.globalForeground),
ClideText('Flutter desktop IDE for Claude Code', muted: true, fontSize: 14),
],
),
],
);
}
}
class _StartColumn extends StatelessWidget {
const _StartColumn({required this.tokens, required this.kernel});
final SurfaceTokens tokens;
final KernelServices kernel;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('START', fontSize: 11, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 16),
_ActionRow(
icon: PhosphorIcons.folder,
label: 'Open folder…',
shortcut: '⌘O',
tokens: tokens,
onTap: () => _openFolder(context),
),
_ActionRow(
icon: PhosphorIcons.gitBranch,
label: 'Clone from git…',
shortcut: '⌘G',
tokens: tokens,
onTap: () {},
),
_ActionRow(
icon: PhosphorIcons.chatCircle,
label: 'Start a Claude session',
shortcut: '⌘C',
tokens: tokens,
onTap: () {},
),
],
);
}
void _openProject(BuildContext context) {
final kernel = ClideKernel.of(context);
void _openFolder(BuildContext context) {
kernel.dialog.show<String>((ctx, dismiss) {
return _OpenProjectDialog(
onOpen: (path) async {
@@ -79,6 +113,174 @@ class WelcomeView extends StatelessWidget {
}
}
class _ActionRow extends StatefulWidget {
const _ActionRow({required this.icon, required this.label, this.shortcut, required this.tokens, required this.onTap});
final ClideIconPainter icon;
final String label;
final String? shortcut;
final SurfaceTokens tokens;
final VoidCallback onTap;
@override
State<_ActionRow> createState() => _ActionRowState();
}
class _ActionRowState extends State<_ActionRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _hover ? widget.tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
ClideIcon(widget.icon, size: 16, color: widget.tokens.globalTextMuted),
const SizedBox(width: 12),
Expanded(child: ClideText(widget.label, fontSize: 14, color: widget.tokens.globalForeground)),
if (widget.shortcut != null)
ClideText(widget.shortcut!, fontSize: 12, color: widget.tokens.globalTextMuted, fontFamily: clideMonoFamily),
],
),
),
),
);
}
}
class _RecentColumn extends StatelessWidget {
const _RecentColumn({required this.tokens, required this.kernel});
final SurfaceTokens tokens;
final KernelServices kernel;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: kernel.project,
builder: (ctx, _) {
final recents = kernel.project.recents;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText('RECENT', fontSize: 11, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
const SizedBox(height: 16),
if (recents.isEmpty)
const ClideText('No recent projects.', muted: true, fontSize: 13)
else
for (final r in recents)
_RecentRow(project: r, tokens: tokens, onTap: () => _openRecent(r.path)),
],
);
},
);
}
void _openRecent(String path) {
kernel.project.open(path).then((ok) {
if (ok) kernel.panels.activateTab(Slots.workspace, 'claude.primary');
});
}
}
class _RecentRow extends StatefulWidget {
const _RecentRow({required this.project, required this.tokens, required this.onTap});
final RecentProject project;
final SurfaceTokens tokens;
final VoidCallback onTap;
@override
State<_RecentRow> createState() => _RecentRowState();
}
class _RecentRowState extends State<_RecentRow> {
bool _hover = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _hover ? widget.tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(widget.project.name, fontSize: 14, fontWeight: FontWeight.w500),
const SizedBox(height: 2),
Row(
children: [
ClideText(widget.project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
if (widget.project.branch != null) ...[
ClideText(' · ', muted: true, fontSize: 12),
ClideIcon(PhosphorIcons.gitBranch, size: 10, color: widget.tokens.globalTextMuted),
const SizedBox(width: 3),
ClideText(widget.project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
],
],
),
],
),
),
ClideText(widget.project.timeAgo, muted: true, fontSize: 12),
],
),
),
),
);
}
}
class _StatusLine extends StatelessWidget {
const _StatusLine({required this.tokens, required this.kernel});
final SurfaceTokens tokens;
final KernelServices kernel;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: kernel.ipc,
builder: (ctx, _) {
final connected = kernel.ipc.isConnected;
final themeName = kernel.theme.currentName;
return Row(
children: [
ClideText('clide 2.0.0-dev', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
ClideText(' · ', muted: true, fontSize: 12),
ClideText(
connected ? 'daemon connected' : 'daemon disconnected',
fontSize: 12,
fontFamily: clideMonoFamily,
color: connected ? tokens.statusSuccess : tokens.statusError,
),
ClideText(' · ', muted: true, fontSize: 12),
ClideText('theme: ', muted: true, fontSize: 12, fontFamily: clideMonoFamily),
ClideText(themeName, fontSize: 12, fontFamily: clideMonoFamily, color: tokens.globalFocus),
],
);
},
);
}
}
class _OpenProjectDialog extends StatefulWidget {
const _OpenProjectDialog({required this.onOpen, required this.onCancel});
final Future<void> Function(String path) onOpen;
@@ -110,10 +312,7 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
Future<void> _submit() async {
final path = _controller.text.trim();
if (path.isEmpty) return;
setState(() {
_loading = true;
_error = null;
});
setState(() { _loading = true; _error = null; });
try {
await widget.onOpen(path);
} catch (_) {
@@ -175,62 +374,3 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
);
}
}
class _StartAction extends StatefulWidget {
const _StartAction({
required this.label,
required this.icon,
required this.onTap,
this.hint,
});
final String label;
final String? hint;
final ClideIconPainter icon;
final VoidCallback onTap;
@override
State<_StartAction> createState() => _StartActionState();
}
class _StartActionState extends State<_StartAction> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
onTap: widget.onTap,
child: Semantics(
button: true,
label: widget.label,
hint: widget.hint,
child: Container(
width: 280,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: _hover ? tokens.listItemHoverBackground : null,
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
ClideIcon(widget.icon, size: 16, color: tokens.globalFocus),
const SizedBox(width: 12),
Expanded(
child: ClideText(
widget.label,
color: tokens.globalFocus,
),
),
],
),
),
),
),
);
}
}
+73 -10
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:clide_app/kernel/src/events/bus.dart';
@@ -6,6 +7,41 @@ import 'package:clide_app/kernel/src/log.dart';
import 'package:clide_app/kernel/src/settings.dart';
import 'package:flutter/foundation.dart';
class RecentProject {
const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened});
final String path;
final String name;
final String? branch;
final DateTime lastOpened;
Map<String, dynamic> toJson() => {'path': path, 'name': name, 'branch': branch, 'lastOpened': lastOpened.toIso8601String()};
factory RecentProject.fromJson(Map<String, dynamic> json) => RecentProject(
path: json['path'] as String? ?? '',
name: json['name'] as String? ?? '',
branch: json['branch'] as String?,
lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(),
);
String get relativePath {
final home = Platform.environment['HOME'] ?? '';
if (home.isNotEmpty && path.startsWith(home)) return '~${path.substring(home.length)}';
return path;
}
String get timeAgo {
final diff = DateTime.now().difference(lastOpened);
if (diff.inMinutes < 1) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes} min ago';
if (diff.inHours < 24) return '${diff.inHours} hours ago';
if (diff.inDays == 1) return 'yesterday';
if (diff.inDays < 7) return '${diff.inDays} days ago';
if (diff.inDays < 30) return '${(diff.inDays / 7).floor()} weeks ago';
return '${(diff.inDays / 30).floor()} months ago';
}
}
class ProjectManager extends ChangeNotifier {
ProjectManager({
required Logger log,
@@ -23,8 +59,23 @@ class ProjectManager extends ChangeNotifier {
Directory? get current => _current;
bool get isOpen => _current != null;
/// Open a project by path. Runs `git rev-parse --show-toplevel` to
/// find the workspace root. Returns true on success.
List<RecentProject> _recents = [];
List<RecentProject> get recents => List.unmodifiable(_recents);
Future<void> loadRecents() async {
final raw = _settings.get<String>('app.recentProjects');
if (raw == null || raw.isEmpty) {
_recents = [];
return;
}
try {
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
_recents = list.map(RecentProject.fromJson).toList();
} catch (_) {
_recents = [];
}
}
Future<bool> open(String path) async {
final root = await resolveWorkspace(path);
if (root == null) {
@@ -34,6 +85,14 @@ class ProjectManager extends ChangeNotifier {
_current = Directory(root);
await _settings.setProjectDir(_current);
await _settings.set<String>('app.lastProject', root);
final branch = await _currentBranch(root);
final name = root.split('/').last;
_recents.removeWhere((r) => r.path == root);
_recents.insert(0, RecentProject(path: root, name: name, branch: branch, lastOpened: DateTime.now()));
if (_recents.length > 10) _recents = _recents.sublist(0, 10);
await _settings.set<String>('app.recentProjects', jsonEncode(_recents.map((r) => r.toJson()).toList()));
_events.emit(ProjectOpened(path: root));
notifyListeners();
return true;
@@ -55,16 +114,9 @@ class ProjectManager extends ChangeNotifier {
notifyListeners();
}
/// Walks up from [path] via `git rev-parse --show-toplevel`. Returns
/// null if the path is outside a git repo or git isn't available.
Future<String?> resolveWorkspace(String path) async {
try {
final r = await Process.run(
'git',
['rev-parse', '--show-toplevel'],
workingDirectory: path,
runInShell: false,
);
final r = await Process.run('git', ['rev-parse', '--show-toplevel'], workingDirectory: path, runInShell: false);
if (r.exitCode != 0) return null;
final out = (r.stdout as String).trim();
return out.isEmpty ? null : out;
@@ -73,4 +125,15 @@ class ProjectManager extends ChangeNotifier {
return null;
}
}
Future<String?> _currentBranch(String root) async {
try {
final r = await Process.run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], workingDirectory: root, runInShell: false);
if (r.exitCode != 0) return null;
final out = (r.stdout as String).trim();
return out.isEmpty ? null : out;
} catch (_) {
return null;
}
}
}
+5 -1
View File
@@ -83,7 +83,11 @@ Future<void> main() async {
await services.extensions.activateAll();
if (!kIsWeb) {
final opened = await services.project.openLast();
await services.project.loadRecents();
var opened = await services.project.openLast();
if (!opened) {
opened = await services.project.open(Directory.current.path);
}
if (opened) {
services.panels.activateTab(Slots.workspace, 'claude.primary');
}