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'); }