From 16ecf5109162036293a37ed54380cbbbe34237b4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 23:35:21 +0200 Subject: [PATCH] redesign welcome as full-screen overlay with recent projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 13 +- app/lib/app.dart | 35 +- app/lib/builtin/welcome/src/welcome_view.dart | 358 ++++++++++++------ app/lib/kernel/src/project.dart | 83 +++- app/lib/main.dart | 6 +- 5 files changed, 361 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72489876..699f555e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/lib/app.dart b/app/lib/app.dart index 408af565..030b8f3f 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -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(), + ); + }, + ); + } +} diff --git a/app/lib/builtin/welcome/src/welcome_view.dart b/app/lib/builtin/welcome/src/welcome_view.dart index b832bd28..d61bd026 100644 --- a/app/lib/builtin/welcome/src/welcome_view.dart +++ b/app/lib/builtin/welcome/src/welcome_view.dart @@ -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((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 Function(String path) onOpen; @@ -110,10 +312,7 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> { Future _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, - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/kernel/src/project.dart b/app/lib/kernel/src/project.dart index 699c6260..5d56c1d1 100644 --- a/app/lib/kernel/src/project.dart +++ b/app/lib/kernel/src/project.dart @@ -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 toJson() => {'path': path, 'name': name, 'branch': branch, 'lastOpened': lastOpened.toIso8601String()}; + + factory RecentProject.fromJson(Map 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 _recents = []; + List get recents => List.unmodifiable(_recents); + + Future loadRecents() async { + final raw = _settings.get('app.recentProjects'); + if (raw == null || raw.isEmpty) { + _recents = []; + return; + } + try { + final list = (jsonDecode(raw) as List).cast>(); + _recents = list.map(RecentProject.fromJson).toList(); + } catch (_) { + _recents = []; + } + } + Future 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('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('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 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 _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; + } + } } diff --git a/app/lib/main.dart b/app/lib/main.dart index e87d417e..b5c58a07 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -83,7 +83,11 @@ Future 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'); }