diff --git a/packages/playground/lib/app/providers.dart b/packages/playground/lib/app/providers.dart index 759e1214..25661333 100644 --- a/packages/playground/lib/app/providers.dart +++ b/packages/playground/lib/app/providers.dart @@ -3,6 +3,8 @@ import 'package:hero_ui/hero_ui.dart'; import 'package:provider/provider.dart'; import 'package:superdeck/superdeck.dart'; +import '../core/data/data_sources/app_settings_store.dart'; +import '../core/data/data_sources/deck_file_store.dart'; import '../core/data/data_sources/memory_asset_cache_store.dart'; import '../core/data/data_sources/memory_deck_loader.dart'; import '../core/domain/stores/deck_customization_store.dart'; @@ -17,10 +19,21 @@ import '../core/domain/stores/deck_customization_store.dart'; /// /// The editor's `EditorStore` is provided at its route instead. Slides are read /// straight off `DeckController.slides` (a signal) in the UI — no bridge store. +/// +/// [deckFileStore]/[appSettingsStore] default to the native (filesystem) +/// implementations; tests inject in-memory fakes so the editor's file-backed +/// bootstrap runs without touching disk. class AppProviders extends StatelessWidget { - const AppProviders({required this.child, super.key}); + const AppProviders({ + required this.child, + this.deckFileStore, + this.appSettingsStore, + super.key, + }); final Widget child; + final DeckFileStore? deckFileStore; + final AppSettingsStore? appSettingsStore; @override Widget build(BuildContext context) { @@ -35,8 +48,12 @@ class AppProviders extends StatelessWidget { create: (_) => MemoryDeckLoader(), dispose: (_, loader) => loader.dispose(), ), - Provider( - create: (_) => MemoryAssetCacheStore(), + Provider(create: (_) => MemoryAssetCacheStore()), + Provider( + create: (_) => deckFileStore ?? NativeDeckFileStore(), + ), + Provider( + create: (_) => appSettingsStore ?? NativeAppSettingsStore(), ), Provider( lazy: false, diff --git a/packages/playground/lib/core/data/data_sources/app_settings_store.dart b/packages/playground/lib/core/data/data_sources/app_settings_store.dart new file mode 100644 index 00000000..9e173cc3 --- /dev/null +++ b/packages/playground/lib/core/data/data_sources/app_settings_store.dart @@ -0,0 +1,7 @@ +/// Entry point for the app settings store: re-exports the interface plus the +/// platform-selected [NativeAppSettingsStore] via conditional import. +library; + +export 'app_settings_store_base.dart'; +export 'app_settings_store_stub.dart' + if (dart.library.io) 'app_settings_store_io.dart'; diff --git a/packages/playground/lib/core/data/data_sources/app_settings_store_base.dart b/packages/playground/lib/core/data/data_sources/app_settings_store_base.dart new file mode 100644 index 00000000..b6a4870d --- /dev/null +++ b/packages/playground/lib/core/data/data_sources/app_settings_store_base.dart @@ -0,0 +1,15 @@ +/// Persists the playground's single app-managed setting: the last-opened deck +/// path, so the editor can reopen it on the next launch. +/// +/// Kept behind an interface (native impl + web stub) to match the storage +/// pattern used elsewhere in the app. +abstract class AppSettingsStore { + const AppSettingsStore(); + + /// The absolute path of the last-opened deck, or `null` if none is + /// remembered (first run) or it could not be read. + Future lastOpenedDeckPath(); + + /// Remembers [path] as the last-opened deck. + Future setLastOpenedDeckPath(String path); +} diff --git a/packages/playground/lib/core/data/data_sources/app_settings_store_io.dart b/packages/playground/lib/core/data/data_sources/app_settings_store_io.dart new file mode 100644 index 00000000..f4d4251a --- /dev/null +++ b/packages/playground/lib/core/data/data_sources/app_settings_store_io.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'app_settings_store_base.dart'; + +/// Native [AppSettingsStore] backed by a small JSON file in the app-support +/// directory (`.../superdeck_playground/settings.json`). +/// +/// Deliberately dependency-free (no `shared_preferences`) — one file, one key. +class NativeAppSettingsStore extends AppSettingsStore { + NativeAppSettingsStore(); + + static const _settingsFolder = 'superdeck_playground'; + static const _settingsFileName = 'settings.json'; + static const _lastOpenedKey = 'lastOpenedDeckPath'; + + Future _settingsFile() async { + final support = await getApplicationSupportDirectory(); + final dir = Directory(p.join(support.path, _settingsFolder)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return File(p.join(dir.path, _settingsFileName)); + } + + Future> _read() async { + try { + final file = await _settingsFile(); + if (!await file.exists()) return {}; + final decoded = jsonDecode(await file.readAsString()); + return decoded is Map ? Map.from(decoded) : {}; + } catch (_) { + // Corrupt/unreadable settings are non-fatal: treat as empty. + return {}; + } + } + + @override + Future lastOpenedDeckPath() async { + final value = (await _read())[_lastOpenedKey]; + return value is String && value.isNotEmpty ? value : null; + } + + @override + Future setLastOpenedDeckPath(String path) async { + final settings = await _read(); + settings[_lastOpenedKey] = path; + final file = await _settingsFile(); + await file.writeAsString(jsonEncode(settings)); + } +} diff --git a/packages/playground/lib/core/data/data_sources/app_settings_store_stub.dart b/packages/playground/lib/core/data/data_sources/app_settings_store_stub.dart new file mode 100644 index 00000000..7b167bba --- /dev/null +++ b/packages/playground/lib/core/data/data_sources/app_settings_store_stub.dart @@ -0,0 +1,13 @@ +import 'app_settings_store_base.dart'; + +/// Web/unsupported stub for [AppSettingsStore]. Desktop-only today; nothing is +/// persisted. +class NativeAppSettingsStore extends AppSettingsStore { + NativeAppSettingsStore(); + + @override + Future lastOpenedDeckPath() async => null; + + @override + Future setLastOpenedDeckPath(String path) async {} +} diff --git a/packages/playground/lib/core/data/data_sources/deck_file_store.dart b/packages/playground/lib/core/data/data_sources/deck_file_store.dart new file mode 100644 index 00000000..f97b1d2d --- /dev/null +++ b/packages/playground/lib/core/data/data_sources/deck_file_store.dart @@ -0,0 +1,8 @@ +/// Entry point for the deck file store: re-exports the interface plus the +/// platform-selected [NativeDeckFileStore] via conditional import (native +/// `dart:io` impl, web stub), mirroring `AssetCacheStore`'s pattern. +library; + +export 'deck_file_store_base.dart'; +export 'deck_file_store_stub.dart' + if (dart.library.io) 'deck_file_store_io.dart'; diff --git a/packages/playground/lib/core/data/data_sources/deck_file_store_base.dart b/packages/playground/lib/core/data/data_sources/deck_file_store_base.dart new file mode 100644 index 00000000..fc93c441 --- /dev/null +++ b/packages/playground/lib/core/data/data_sources/deck_file_store_base.dart @@ -0,0 +1,67 @@ +/// Storage contract for deck `.md` files owned by the playground editor. +/// +/// Mirrors the `DeckLoader` / `AssetCacheStore` interface + native-impl pattern: +/// the editor talks only to this abstraction, and the concrete implementation is +/// selected per platform through a conditional export (`deck_file_store.dart`). +/// Only the native (`dart:io` + `path_provider` + `file_picker`) implementation +/// is built today; the web stub throws. +abstract class DeckFileStore { + const DeckFileStore(); + + /// Absolute path of the fixed decks folder (`~/Documents/SuperDeck/`), + /// creating it if it does not yet exist. + Future decksDirectoryPath(); + + /// Whether a file exists at [path]. + Future exists(String path); + + /// Reads the file at [path] as UTF-8 text. + /// + /// Throws [DeckFileReadException] if the file cannot be read. + Future read(String path); + + /// Writes [content] to the file at [path] as UTF-8 text. + Future write(String path, String content); + + /// Creates `.md` in the decks folder seeded with [content] and returns + /// its absolute path. + /// + /// Throws [DeckNameCollisionException] if a file with that name already + /// exists — callers re-prompt rather than overwrite. + Future createDeck(String name, {required String content}); + + /// Opens a native file picker filtered to `.md` and returns the chosen + /// absolute path, or `null` if the user cancelled. + Future pickDeckFile(); + + /// Emits an event whenever the file at [path] changes on disk (external edit, + /// deletion, or move). Backed by the shared `FileWatcher` (FS events + + /// polling fallback). + Stream watch(String path); +} + +/// Thrown by [DeckFileStore.createDeck] when the target name already exists. +class DeckNameCollisionException implements Exception { + const DeckNameCollisionException(this.fileName); + + /// The `.md` filename that collided. + final String fileName; + + @override + String toString() => 'A deck named "$fileName" already exists.'; +} + +/// Thrown by [DeckFileStore.read] when a file cannot be read (missing, +/// permissions, invalid encoding). +class DeckFileReadException implements Exception { + const DeckFileReadException(this.path, this.cause); + + /// The path that failed to read. + final String path; + + /// The underlying error. + final Object cause; + + @override + String toString() => 'Could not read deck file "$path": $cause'; +} diff --git a/packages/playground/lib/core/data/data_sources/deck_file_store_io.dart b/packages/playground/lib/core/data/data_sources/deck_file_store_io.dart new file mode 100644 index 00000000..b1364507 --- /dev/null +++ b/packages/playground/lib/core/data/data_sources/deck_file_store_io.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +import 'deck_file_store_base.dart'; + +/// Native ([dart:io]) implementation of [DeckFileStore]. +/// +/// Decks live in `~/Documents/SuperDeck/` (via `path_provider`); `pickDeckFile` +/// can still reach any path on disk. +class NativeDeckFileStore extends DeckFileStore { + NativeDeckFileStore(); + + /// Fixed decks-folder name under the documents directory. + static const _decksFolderName = 'SuperDeck'; + + @override + Future decksDirectoryPath() async { + final documents = await getApplicationDocumentsDirectory(); + final dir = Directory(p.join(documents.path, _decksFolderName)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir.path; + } + + @override + Future exists(String path) => File(path).exists(); + + @override + Future read(String path) async { + try { + return await File(path).readAsString(); + } catch (error) { + throw DeckFileReadException(path, error); + } + } + + @override + Future write(String path, String content) async { + final file = File(path); + await file.parent.create(recursive: true); + await file.writeAsString(content); + } + + @override + Future createDeck(String name, {required String content}) async { + final dir = await decksDirectoryPath(); + final fileName = _toMarkdownFileName(name); + final path = p.join(dir, fileName); + final file = File(path); + if (await file.exists()) { + throw DeckNameCollisionException(fileName); + } + await file.writeAsString(content); + return path; + } + + @override + Future pickDeckFile() async { + final result = await FilePicker.platform.pickFiles( + dialogTitle: 'Open deck', + type: FileType.custom, + allowedExtensions: const ['md'], + ); + // Treat an empty/oddly-shaped result or a path-less entry as a cancel + // rather than letting `.single` throw a StateError (which openDeck's + // narrow catch would not handle) escape the Open action. + final files = result?.files; + if (files == null || files.isEmpty) return null; + return files.first.path; + } + + @override + Stream watch(String path) => FileWatcher( + File(path), + // Watch for edits, deletion, and moves so the controller can react to + // external changes and to the file disappearing. + events: + FileSystemEvent.modify | FileSystemEvent.delete | FileSystemEvent.move, + ).watch(); + + /// Normalises a user-typed deck name into a safe bare `.md` filename: + /// strips any directory components and ensures the `.md` extension. + String _toMarkdownFileName(String name) { + final bare = p.basename(name.trim()); + if (bare.isEmpty) { + throw ArgumentError.value(name, 'name', 'Deck name must not be empty'); + } + return p.extension(bare).toLowerCase() == '.md' ? bare : '$bare.md'; + } +} diff --git a/packages/playground/lib/core/data/data_sources/deck_file_store_stub.dart b/packages/playground/lib/core/data/data_sources/deck_file_store_stub.dart new file mode 100644 index 00000000..733e6997 --- /dev/null +++ b/packages/playground/lib/core/data/data_sources/deck_file_store_stub.dart @@ -0,0 +1,37 @@ +import 'deck_file_store_base.dart'; + +/// Web/unsupported stub for [DeckFileStore]. +/// +/// The playground builds desktop-only today; filesystem persistence is native. +/// This stub keeps the conditional export well-typed on platforms without +/// `dart:io`. An IndexedDB implementation could replace it later (see the +/// `AssetCacheStore` web impl for the pattern). +class NativeDeckFileStore extends DeckFileStore { + NativeDeckFileStore(); + + Never _unsupported() => throw UnsupportedError( + 'Deck filesystem persistence is only available on desktop platforms.', + ); + + @override + Future decksDirectoryPath() => _unsupported(); + + @override + Future exists(String path) => _unsupported(); + + @override + Future read(String path) => _unsupported(); + + @override + Future write(String path, String content) => _unsupported(); + + @override + Future createDeck(String name, {required String content}) => + _unsupported(); + + @override + Future pickDeckFile() => _unsupported(); + + @override + Stream watch(String path) => _unsupported(); +} diff --git a/packages/playground/lib/features/editor/domain/stores/deck_file_controller.dart b/packages/playground/lib/features/editor/domain/stores/deck_file_controller.dart new file mode 100644 index 00000000..ded4cf67 --- /dev/null +++ b/packages/playground/lib/features/editor/domain/stores/deck_file_controller.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import '../../../../core/data/data_sources/app_settings_store.dart'; +import '../../../../core/data/data_sources/deck_file_store.dart'; +import '../../utils/markdown_editor.dart'; + +/// Markdown a new (or first-run default) deck is seeded with, so the live +/// preview is never blank. +const kStarterDeckMarkdown = '''--- + +# Title +## Subtitle + +--- +'''; + +/// Filename of the first-run default deck, born in `~/Documents/SuperDeck/`. +const _defaultDeckFileName = 'untitled.md'; + +/// Whether the editor is currently backed by a real file on disk. +enum DeckBindingStatus { + /// Edits auto-save to [DeckFileController.boundPath]. + bound, + + /// The bound file was deleted/moved: edits are kept in memory only, and the + /// user is warned to `New`/`Open` to persist again. + unbound, +} + +/// Binds the editor to a real `.md` file and keeps them in sync. +/// +/// Responsibilities: +/// - **Auto-save**: debounced writes of every edit back to [boundPath]; there +/// is no manual save and no dirty flag. +/// - **Watch + self-write filtering**: watches the bound file and ignores the +/// app's own writes (by comparing the content it last synced) so auto-save +/// doesn't loop. +/// - **External wins**: an external change to the bound file auto-reloads into +/// the editor. +/// - **Unbind on loss**: a deleted/moved file stops auto-save, keeps the +/// in-memory content, and surfaces a warning — never resurrecting the file. +/// - **New/Open/launch**: create a named deck, open one from anywhere, and +/// reopen the last-opened file on launch (falling back to a default deck). +/// +/// The editor document itself lives in `TextEditorController`; this controller +/// drives it through the [MarkdownEditor] port for reloads and receives edits +/// through [handleEditorChange]. +class DeckFileController extends ChangeNotifier { + DeckFileController({ + required DeckFileStore store, + required AppSettingsStore settings, + Duration autoSaveDebounce = const Duration(milliseconds: 400), + }) : _store = store, + _settings = settings, + _autoSaveDebounce = autoSaveDebounce; + + final DeckFileStore _store; + final AppSettingsStore _settings; + final Duration _autoSaveDebounce; + + MarkdownEditor? _editor; + + String? _boundPath; + DeckBindingStatus _status = DeckBindingStatus.bound; + String _content = kStarterDeckMarkdown; + String? _warning; + + /// The content last read from / written to disk. Auto-saves that match it + /// (and watcher events whose on-disk content matches it) are the app's own + /// writes and are filtered out. Compared by value — a hash would risk a + /// collision silently dropping a genuine external edit. + String _lastSyncedContent = kStarterDeckMarkdown; + + Timer? _debounce; + StreamSubscription? _watchSub; + bool _disposed = false; + + /// Absolute path of the bound file, or `null` before [initialize]. + String? get boundPath => _boundPath; + + /// The bound file's display name (e.g. `deck.md`), or `Untitled` if unbound + /// with no path. + String get fileName => + _boundPath == null ? 'Untitled' : p.basename(_boundPath!); + + /// Current binding status. + DeckBindingStatus get status => _status; + + /// True while edits auto-save to disk. + bool get isBound => _status == DeckBindingStatus.bound; + + /// The latest markdown (kept even while [DeckBindingStatus.unbound]). + String get content => _content; + + /// A user-facing warning (e.g. the bound file vanished, or an open failed), + /// or `null` when everything is healthy. + String? get warning => _warning; + + /// Wires the editor this controller drives on reloads. Called once, right + /// after the editor is built with [content] as its initial text. + void attachEditor(MarkdownEditor editor) => _editor = editor; + + /// Resolves the deck to open on launch and binds to it: + /// the last-opened file if it still exists and reads, otherwise a default + /// deck in `~/Documents/SuperDeck/` (created on first run). + Future initialize() async { + final remembered = await _settings.lastOpenedDeckPath(); + if (remembered != null && await _store.exists(remembered)) { + try { + final content = await _store.read(remembered); + _bindState(remembered, content); + await _rememberAndWatch(remembered); + return; + } on DeckFileReadException { + // Fall through to the default deck. + } + } + final (path, content) = await _ensureDefaultDeck(); + _bindState(path, content); + await _rememberAndWatch(path); + } + + /// Creates `.md` in the decks folder, seeds it with the starter + /// template, and rebinds the editor to it. + /// + /// Throws [DeckNameCollisionException] on a name clash so the dialog can + /// re-prompt. + Future newDeck(String name) async { + final path = await _store.createDeck(name, content: kStarterDeckMarkdown); + await _rebind(path, kStarterDeckMarkdown); + } + + /// Opens a `.md` from anywhere on disk (native picker) and rebinds. + /// + /// A cancelled picker is a no-op. A read failure surfaces a warning and keeps + /// the current file bound. + Future openDeck() async { + final path = await _store.pickDeckFile(); + if (path == null) return; + try { + final content = await _store.read(path); + await _rebind(path, content); + } on DeckFileReadException { + _warning = + 'Could not open "${p.basename(path)}". ' + 'Keeping the current deck.'; + _notify(); + } + } + + /// Receives every editor edit. Debounces a write to the bound file; skips + /// while unbound (content is retained in memory) and skips echoes of our own + /// writes/reloads. + void handleEditorChange(String markdown) { + _content = markdown; + if (_status != DeckBindingStatus.bound || _boundPath == null) return; + + // Cancel any pending save first: if the edit reverted back to the synced + // content within the debounce window, a stale save must not still fire. + _debounce?.cancel(); + if (markdown == _lastSyncedContent) return; + _debounce = Timer(_autoSaveDebounce, () => _flushSave(markdown)); + } + + @override + void dispose() { + _disposed = true; + _debounce?.cancel(); + unawaited(_watchSub?.cancel()); + super.dispose(); + } + + Future _flushSave(String markdown) async { + final path = _boundPath; + if (_status != DeckBindingStatus.bound || path == null) return; + try { + await _store.write(path, markdown); + // Only mark as synced once the write actually succeeds; otherwise the + // marker would claim content that never reached disk, and the next + // watcher event (reading the real, older disk content) would be treated + // as an external change and clobber the editor. The write() future + // resolves before the watcher's own change event is delivered, so this + // still filters out the self-write. + _lastSyncedContent = markdown; + } catch (_) { + // Best-effort auto-save; a subsequent edit will retry. If the file is + // gone the watcher will unbind and warn. + } + } + + Future _rebind(String path, String content) async { + _bindState(path, content); + _editor?.replaceMarkdown(content); + await _rememberAndWatch(path); + } + + /// Updates in-memory binding state (path, content, synced marker, status) + /// without any I/O or editor/side effects. + void _bindState(String path, String content) { + _debounce?.cancel(); + _boundPath = path; + _content = content; + _lastSyncedContent = content; + _status = DeckBindingStatus.bound; + _warning = null; + } + + Future _rememberAndWatch(String path) async { + await _settings.setLastOpenedDeckPath(path); + _startWatching(path); + _notify(); + } + + void _startWatching(String path) { + unawaited(_watchSub?.cancel()); + _watchSub = _store + .watch(path) + .listen((_) => _onFileChanged(), onError: (_) {}); + } + + Future _onFileChanged() async { + final path = _boundPath; + if (path == null || _status != DeckBindingStatus.bound) return; + + if (!await _store.exists(path)) { + _handleFileLost(); + return; + } + + String diskContent; + try { + diskContent = await _store.read(path); + } on DeckFileReadException { + _handleFileLost(); + return; + } + + // Self-write filter: our own auto-save produced this content. + if (diskContent == _lastSyncedContent) return; + + // External change → external wins → reload into the editor. Cancel any + // pending auto-save so a stale in-flight edit can't clobber the new content. + _debounce?.cancel(); + _content = diskContent; + _lastSyncedContent = diskContent; + _editor?.replaceMarkdown(diskContent); + _notify(); + } + + void _handleFileLost() { + unawaited(_watchSub?.cancel()); + _watchSub = null; + _debounce?.cancel(); + _status = DeckBindingStatus.unbound; + _warning = + 'The file "$fileName" is no longer on disk. ' + 'Your work is kept here — use New or Open to save it to a file.'; + _notify(); + } + + Future<(String, String)> _ensureDefaultDeck() async { + final dir = await _store.decksDirectoryPath(); + final path = p.join(dir, _defaultDeckFileName); + if (await _store.exists(path)) { + try { + return (path, await _store.read(path)); + } on DeckFileReadException { + // Recreate below if it can't be read. + } + } + await _store.write(path, kStarterDeckMarkdown); + return (path, kStarterDeckMarkdown); + } + + void _notify() { + if (_disposed) return; + notifyListeners(); + } +} diff --git a/packages/playground/lib/features/editor/presentation/pages/editor_bootstrap.dart b/packages/playground/lib/features/editor/presentation/pages/editor_bootstrap.dart new file mode 100644 index 00000000..0340d1f5 --- /dev/null +++ b/packages/playground/lib/features/editor/presentation/pages/editor_bootstrap.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:provider/provider.dart'; + +import '../../../../core/data/data_sources/app_settings_store.dart'; +import '../../../../core/data/data_sources/deck_file_store.dart'; +import '../../../../core/data/data_sources/memory_deck_loader.dart'; +import '../../../ai/quick_agent/domain/commands/generate_deck_command.dart'; +import '../../domain/stores/deck_file_controller.dart'; +import '../../domain/stores/editor_store.dart'; +import '../../utils/text_editor_controller.dart'; +import 'editor_page.dart'; + +/// Resolves the file-backed deck before the editor mounts. +/// +/// The editor must be seeded with the last-opened file's content (loaded +/// asynchronously), so this widget owns the [DeckFileController], awaits its +/// [DeckFileController.initialize], and only then builds the editor's scoped +/// providers — wiring the controller's auto-save sink and reload port to the +/// freshly-built [TextEditorController]. +class EditorBootstrap extends StatefulWidget { + const EditorBootstrap({super.key}); + + @override + State createState() => _EditorBootstrapState(); +} + +class _EditorBootstrapState extends State { + late final DeckFileController _fileController; + late Future _ready; + + @override + void initState() { + super.initState(); + _fileController = DeckFileController( + store: context.read(), + settings: context.read(), + ); + _ready = _fileController.initialize(); + } + + @override + void dispose() { + _fileController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _ready, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return _BootstrapMessage( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: $accent.resolve(context), + ), + ), + ); + } + if (snapshot.hasError) { + return _BootstrapMessage( + child: Text( + 'Could not open the decks folder.\n${snapshot.error}', + textAlign: TextAlign.center, + style: TextStyle(color: $muted.resolve(context)), + ), + ); + } + return _buildEditor(context); + }, + ); + } + + Widget _buildEditor(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: _fileController, + ), + ChangeNotifierProvider(create: (_) => EditorStore()), + Provider( + lazy: false, + create: (ctx) { + final controller = TextEditorController( + editorStore: ctx.read(), + deckLoader: ctx.read(), + initialText: _fileController.content, + onMarkdownChanged: _fileController.handleEditorChange, + ); + _fileController.attachEditor(controller); + return controller; + }, + dispose: (_, controller) => controller.dispose(), + ), + ListenableProvider( + create: (ctx) => + GenerateDeckCommand(editor: ctx.read()), + dispose: (_, command) => command.dispose(), + ), + ], + child: const EditorPage(), + ); + } +} + +/// Full-screen centered slot used for the loading spinner and error message. +class _BootstrapMessage extends StatelessWidget { + const _BootstrapMessage({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: $background.resolve(context), + body: Center(child: child), + ); + } +} diff --git a/packages/playground/lib/features/editor/presentation/pages/editor_page.dart b/packages/playground/lib/features/editor/presentation/pages/editor_page.dart index d3dd3ee6..1d0ae9d8 100644 --- a/packages/playground/lib/features/editor/presentation/pages/editor_page.dart +++ b/packages/playground/lib/features/editor/presentation/pages/editor_page.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:mix/mix.dart'; import 'package:provider/provider.dart'; +import '../../domain/stores/deck_file_controller.dart'; import '../../domain/stores/editor_store.dart'; import '../widgets/customization_sidebar.dart'; import '../widgets/editor_controls.dart'; +import '../widgets/new_deck_dialog.dart'; import '../widgets/preview_sidebar.dart'; import '../widgets/text_editor.dart'; @@ -15,67 +18,86 @@ class EditorPage extends StatelessWidget { @override Widget build(BuildContext context) { final store = context.watch(); + final fileController = context.read(); - return Scaffold( - backgroundColor: $background.resolve(context), - body: Box( - style: BoxStyler().color($background()), - child: RowBox( - children: [ - _AnimatedSidebar( - visible: store.showPreviewSidebar, - alignment: Alignment.centerLeft, - child: const PreviewSidebar(), - ), + // ⌘N / ⌘O drive the file operations; the file operations otherwise live in + // the header. Focus (autofocus) gives the shortcuts a target so they work + // before the editor is clicked, without stealing focus once it is. + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyN, meta: true): () => + showNewDeckDialog(context, fileController), + const SingleActivator(LogicalKeyboardKey.keyO, meta: true): + fileController.openDeck, + }, + child: Focus( + autofocus: true, + child: Scaffold( + backgroundColor: $background.resolve(context), + body: Box( + style: BoxStyler().color($background()), + child: RowBox( + children: [ + _AnimatedSidebar( + visible: store.showPreviewSidebar, + alignment: Alignment.centerLeft, + child: const PreviewSidebar(), + ), - Expanded( - child: Stack( - children: [ - TextEditor(), - Align( - alignment: .bottomCenter, - child: Padding( - padding: const .only(bottom: 24), - child: EditorControls( - showPreviewSidebar: store.showPreviewSidebar, - showCustomizationSidebar: - store.showCustomizationSidebar, - onTogglePreviewSidebar: store.togglePreviewSidebar, - onToggleCustomizationSidebar: - store.toggleCustomizationSidebar, - ), - ), - ), - if (store.showPreviewSidebar) - Align( - alignment: .centerLeft, - child: Transform.translate( - offset: Offset(-4, 0), - child: _SidebarResizeHandle( - onDrag: (delta) => store.previewSidebarWidth += delta, + // The filename bar sits on top of the editor pane only, not + // spanning the sidebars. + Expanded( + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + TextEditor(), + Align( + alignment: .bottomCenter, + child: Padding( + padding: const .only(bottom: 24), + child: EditorControls( + showPreviewSidebar: store.showPreviewSidebar, + showCustomizationSidebar: + store.showCustomizationSidebar, + onTogglePreviewSidebar: + store.togglePreviewSidebar, + onToggleCustomizationSidebar: + store.toggleCustomizationSidebar, + ), + ), + ), + if (store.showPreviewSidebar) + Align( + alignment: .centerLeft, + child: _SidebarResizeHandle( + onDrag: (delta) => + store.previewSidebarWidth += delta, + ), + ), + if (store.showCustomizationSidebar) + Align( + alignment: .centerRight, + child: _SidebarResizeHandle( + onDrag: (delta) => + store.customizationSidebarWidth -= delta, + ), + ), + ], ), ), - ), - if (store.showCustomizationSidebar) - Align( - alignment: .centerRight, - child: Transform.translate( - offset: Offset(4, 0), - child: _SidebarResizeHandle( - onDrag: (delta) => - store.customizationSidebarWidth -= delta, - ), - ), - ), - ], - ), - ), - _AnimatedSidebar( - visible: store.showCustomizationSidebar, - alignment: Alignment.centerRight, - child: const CustomizationSidebar(), + ], + ), + ), + _AnimatedSidebar( + visible: store.showCustomizationSidebar, + alignment: Alignment.centerRight, + child: const CustomizationSidebar(), + ), + ], ), - ], + ), ), ), ); diff --git a/packages/playground/lib/features/editor/presentation/widgets/editor_header.dart b/packages/playground/lib/features/editor/presentation/widgets/editor_header.dart new file mode 100644 index 00000000..94233b1c --- /dev/null +++ b/packages/playground/lib/features/editor/presentation/widgets/editor_header.dart @@ -0,0 +1,120 @@ +import 'package:flutter/cupertino.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:mix/mix.dart'; +import 'package:provider/provider.dart'; + +import '../../domain/stores/deck_file_controller.dart'; +import 'new_deck_dialog.dart'; + +/// Bar sitting on top of the text editor: the `New` / `Open` actions on the +/// left, followed by the bound deck's filename. When the bound file is lost +/// (deleted/moved) it also surfaces the controller's warning as a banner. +class EditorHeader extends StatelessWidget { + const EditorHeader({super.key}); + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + final warning = controller.warning; + + return ColumnBox( + style: FlexBoxStyler().mainAxisSize(.min), + children: [ + Box( + style: BoxStyler() + .width(double.infinity) + .color($backgroundSecondary()) + .padding(.horizontal(16).vertical(8)) + .borderBottom(color: $border()), + child: RowBox( + style: FlexBoxStyler() + .mainAxisAlignment(.start) + .crossAxisAlignment(.center) + .spacing(8), + children: [ + _FileName( + name: controller.fileName, + unbound: !controller.isBound, + ), + Spacer(), + Box(style: BoxStyler().width(1).height(16).color($border())), + HeroButton( + label: 'New', + iconLeft: CupertinoIcons.add, + size: .sm, + variant: .ghost, + onPressed: () => showNewDeckDialog(context, controller), + ), + HeroButton( + label: 'Open', + iconLeft: CupertinoIcons.folder, + size: .sm, + variant: .ghost, + onPressed: controller.openDeck, + ), + ], + ), + ), + if (warning != null) _WarningBanner(message: warning), + ], + ); + } +} + +class _FileName extends StatelessWidget { + const _FileName({required this.name, required this.unbound}); + + final String name; + final bool unbound; + + @override + Widget build(BuildContext context) { + return RowBox( + style: FlexBoxStyler().mainAxisSize(.min).spacing(8), + children: [ + StyledIcon( + icon: unbound + ? CupertinoIcons.exclamationmark_triangle_fill + : CupertinoIcons.doc_text, + style: IconStyler().size(15).color(unbound ? $warning() : $muted()), + ), + StyledText( + name, + style: TextStyler() + .color(unbound ? $warning() : $foreground()) + .style($labelSmall.mix()), + ), + ], + ); + } +} + +class _WarningBanner extends StatelessWidget { + const _WarningBanner({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Box( + style: BoxStyler() + .width(double.infinity) + .color($warning().withValues(alpha: 0.12)) + .padding(.horizontal(16).vertical(8)), + child: RowBox( + style: FlexBoxStyler().mainAxisSize(.min).spacing(8), + children: [ + Icon( + CupertinoIcons.exclamationmark_triangle_fill, + size: 14, + color: $warning.resolve(context), + ), + StyledText( + message, + style: TextStyler().color($warning()).style($labelSmall.mix()), + ), + ], + ), + ); + } +} diff --git a/packages/playground/lib/features/editor/presentation/widgets/new_deck_dialog.dart b/packages/playground/lib/features/editor/presentation/widgets/new_deck_dialog.dart new file mode 100644 index 00000000..3d193fbc --- /dev/null +++ b/packages/playground/lib/features/editor/presentation/widgets/new_deck_dialog.dart @@ -0,0 +1,149 @@ +import 'package:flutter/cupertino.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:remix/remix.dart'; + +import '../../../../core/data/data_sources/deck_file_store.dart'; +import '../../domain/stores/deck_file_controller.dart'; + +/// Opens the "New deck" dialog: prompts for a name, creates `.md` in +/// `~/Documents/SuperDeck/`, and rebinds the editor. On a name collision it +/// re-prompts with an inline error rather than overwriting. +/// +/// [controller] is captured from the editor's provider scope; the dialog route +/// does not inherit it reliably. +Future showNewDeckDialog( + BuildContext context, + DeckFileController controller, +) { + return showRemixDialog( + context: context, + barrierDismissible: true, + builder: (_) => _NewDeckDialog(controller: controller), + ); +} + +class _NewDeckDialog extends StatefulWidget { + const _NewDeckDialog({required this.controller}); + + final DeckFileController controller; + + @override + State<_NewDeckDialog> createState() => _NewDeckDialogState(); +} + +class _NewDeckDialogState extends State<_NewDeckDialog> { + late final TextEditingController _textController; + late final FocusNode _focusNode; + String? _error; + bool _creating = false; + + @override + void initState() { + super.initState(); + _textController = TextEditingController(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _create() async { + final name = _textController.text.trim(); + if (name.isEmpty) { + setState(() => _error = 'Enter a name for your deck.'); + return; + } + setState(() { + _creating = true; + _error = null; + }); + try { + await widget.controller.newDeck(name); + if (!mounted) return; + Navigator.of(context).maybePop(); + } on DeckNameCollisionException catch (e) { + if (!mounted) return; + setState(() { + _creating = false; + _error = '${e.fileName} already exists. Choose another name.'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _creating = false; + _error = 'Could not create the deck: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Center( + child: HeroCard( + style: RemixCardStyle().maxWidth(440).paddingAll(24), + child: ColumnBox( + style: FlexBoxStyler() + .mainAxisSize(.min) + .spacing(12) + .crossAxisAlignment(.end), + children: [ + RowBox( + style: FlexBoxStyler().mainAxisAlignment(.spaceBetween), + children: [ + StyledText( + 'New deck', + style: TextStyler() + .color($foreground()) + .style($labelMedium.mix()), + ), + SizedBox( + width: 36, + child: HeroIconButton( + icon: CupertinoIcons.xmark, + size: .sm, + variant: .ghost, + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + ], + ), + HeroTextField( + fullWidth: true, + autofocus: true, + controller: _textController, + focusNode: _focusNode, + enabled: !_creating, + error: _error != null, + hintText: 'my-presentation', + helperText: 'Saved as a .md file in Documents/SuperDeck', + textInputAction: TextInputAction.done, + onSubmitted: (_) => _create(), + ), + if (_error != null) + Box( + style: BoxStyler() + .width(double.infinity) + .color($danger().withValues(alpha: 0.1)) + .borderRounded(8) + .paddingAll(12), + child: StyledText( + _error!, + style: TextStyler().color($danger()).style($labelSmall.mix()), + ), + ), + HeroButton( + label: 'Create', + iconLeft: CupertinoIcons.add, + loading: _creating, + onPressed: _creating ? null : _create, + ), + ], + ), + ), + ); + } +} diff --git a/packages/playground/lib/features/editor/presentation/widgets/text_editor.dart b/packages/playground/lib/features/editor/presentation/widgets/text_editor.dart index 34833c9d..651bcd06 100644 --- a/packages/playground/lib/features/editor/presentation/widgets/text_editor.dart +++ b/packages/playground/lib/features/editor/presentation/widgets/text_editor.dart @@ -6,6 +6,7 @@ import 'package:super_editor/super_editor.dart'; import '../../utils/edit_reaction.dart'; import '../../utils/text_editor_controller.dart'; +import 'editor_header.dart'; /// Thin view over [TextEditorController.editor]. The controller owns the /// document model, its reaction pipeline, and all navigation logic; this widget @@ -19,60 +20,73 @@ class TextEditor extends StatelessWidget { final editor = context.read().editor; return Padding( - padding: const .symmetric(vertical: 16.0), + padding: const .symmetric(vertical: 16.0, horizontal: 4), child: HeroCard( - child: SuperEditor( - editor: editor, - keyboardActions: [...defaultKeyboardActions], - documentOverlayBuilders: [ - DefaultCaretOverlayBuilder( - caretStyle: CaretStyle(color: $accent.resolve(context)), - ), - ], - stylesheet: Stylesheet( - rules: [ - StyleRule(BlockSelector.all, (doc, docNode) { - return { - Styles.textStyle: TextStyle( - color: $muted.resolve(context), - fontSize: 16, - height: 1.4, - fontFamily: GoogleFonts.googleSansCode().fontFamily, + child: Column( + children: [ + const EditorHeader(), + Expanded( + child: SuperEditor( + editor: editor, + keyboardActions: [...defaultKeyboardActions], + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle(color: $accent.resolve(context)), ), - }; - }), - ], - inlineTextStyler: (attributions, textStyle) { - var style = const TextStyle(fontSize: 16).merge(textStyle); + ], + stylesheet: Stylesheet( + rules: [ + StyleRule(BlockSelector.all, (doc, docNode) { + return { + Styles.textStyle: TextStyle( + color: $muted.resolve(context), + fontSize: 16, + height: 1.4, + fontFamily: GoogleFonts.googleSansCode().fontFamily, + ), + }; + }), + ], + inlineTextStyler: (attributions, textStyle) { + var style = const TextStyle(fontSize: 16).merge(textStyle); - for (final attribution in attributions) { - switch (attribution) { - case separatorAttribution: - style = style.copyWith( - color: $separatorTertiary.resolve(context), - ); - break; - case headerAttribution: - style = style.copyWith( - fontWeight: FontWeight.bold, - color: $foreground.resolve(context), - ); - break; - case blockAttribution: - style = style.copyWith(color: $danger.resolve(context)); - break; - case blockKeyAttribution: - style = style.copyWith(color: $warning.resolve(context)); - break; - case blockValueAttribution: - style = style.copyWith(color: $foreground.resolve(context)); - break; - } - } - return style; - }, - documentPadding: const .all(32), - ), + for (final attribution in attributions) { + switch (attribution) { + case separatorAttribution: + style = style.copyWith( + color: $separatorTertiary.resolve(context), + ); + break; + case headerAttribution: + style = style.copyWith( + fontWeight: FontWeight.bold, + color: $foreground.resolve(context), + ); + break; + case blockAttribution: + style = style.copyWith( + color: $danger.resolve(context), + ); + break; + case blockKeyAttribution: + style = style.copyWith( + color: $warning.resolve(context), + ); + break; + case blockValueAttribution: + style = style.copyWith( + color: $foreground.resolve(context), + ); + break; + } + } + return style; + }, + documentPadding: const .all(32), + ), + ), + ), + ], ), ), ); diff --git a/packages/playground/lib/features/editor/routes/routes.dart b/packages/playground/lib/features/editor/routes/routes.dart index f0cb0cc5..dde1efd6 100644 --- a/packages/playground/lib/features/editor/routes/routes.dart +++ b/packages/playground/lib/features/editor/routes/routes.dart @@ -1,44 +1,16 @@ import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; -import '../../../core/data/data_sources/memory_deck_loader.dart'; -import '../../ai/quick_agent/domain/commands/generate_deck_command.dart'; -import '../domain/stores/editor_store.dart'; -import '../presentation/pages/editor_page.dart'; -import '../utils/text_editor_controller.dart'; +import '../presentation/pages/editor_bootstrap.dart'; /// The editor feature's routes, scoped at the route level; the deck globals live /// at the app root. The editor route stays mounted beneath a pushed `/present`, /// so these scoped objects survive the round-trip. /// -/// - `EditorStore` holds the shared nav state (active slide). -/// - `TextEditorController` owns the super_editor document; it's eager so it -/// seeds the live preview and registers its caret↔store sync before the view -/// mounts. -/// - `GenerateDeckCommand` drives the controller through the `MarkdownEditor` -/// port. +/// [EditorBootstrap] owns the file-backed deck: it awaits the last-opened file, +/// then provides the editor's scoped objects seeded with its content — +/// `DeckFileController` (binds + auto-saves the `.md`), `EditorStore` (nav +/// state), `TextEditorController` (the super_editor document), and +/// `GenerateDeckCommand` (AI generation via the `MarkdownEditor` port). List editorRoutes() => [ - GoRoute( - path: '/', - builder: (context, state) => MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => EditorStore()), - Provider( - lazy: false, - create: (ctx) => TextEditorController( - editorStore: ctx.read(), - deckLoader: ctx.read(), - ), - dispose: (_, controller) => controller.dispose(), - ), - ListenableProvider( - create: (ctx) => GenerateDeckCommand( - editor: ctx.read(), - ), - dispose: (_, command) => command.dispose(), - ), - ], - child: const EditorPage(), - ), - ), + GoRoute(path: '/', builder: (context, state) => const EditorBootstrap()), ]; diff --git a/packages/playground/lib/features/editor/utils/text_editor_controller.dart b/packages/playground/lib/features/editor/utils/text_editor_controller.dart index 8fef9b13..f9c87b75 100644 --- a/packages/playground/lib/features/editor/utils/text_editor_controller.dart +++ b/packages/playground/lib/features/editor/utils/text_editor_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:super_editor/super_editor.dart'; import '../../../core/data/data_sources/memory_deck_loader.dart'; @@ -30,8 +31,10 @@ class TextEditorController implements MarkdownEditor { required EditorStore editorStore, required MemoryDeckLoader deckLoader, String initialText = starterMarkdown, + ValueChanged? onMarkdownChanged, }) : _editorStore = editorStore, - _deckLoader = deckLoader { + _deckLoader = deckLoader, + _onMarkdownChanged = onMarkdownChanged { _install(initialText); // Seed the live preview with the initial document. _pushMarkdown(initialText); @@ -41,6 +44,10 @@ class TextEditorController implements MarkdownEditor { final EditorStore _editorStore; final MemoryDeckLoader _deckLoader; + /// Notified with the document's plain-text markdown on every real change + /// (deduped like the preview). The file controller uses it to auto-save. + final ValueChanged? _onMarkdownChanged; + late final MutableDocument _document; late final MutableDocumentComposer _composer; late final Editor _editor; @@ -139,6 +146,7 @@ class TextEditorController implements MarkdownEditor { if (markdown == _lastMarkdown) return; _lastMarkdown = markdown; _deckLoader.updateMarkdown(markdown); + _onMarkdownChanged?.call(markdown); } void _moveCaretToSlide(int index) { diff --git a/packages/playground/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/playground/macos/Flutter/GeneratedPluginRegistrant.swift index 17abdc53..14c0bb05 100644 --- a/packages/playground/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/playground/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import audioplayers_darwin +import file_picker import screen_retriever_macos import sqflite_darwin import url_launcher_macos @@ -15,6 +16,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/packages/playground/macos/Runner/DebugProfile.entitlements b/packages/playground/macos/Runner/DebugProfile.entitlements index 08c3ab17..cff5a4b3 100644 --- a/packages/playground/macos/Runner/DebugProfile.entitlements +++ b/packages/playground/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/packages/playground/macos/Runner/Release.entitlements b/packages/playground/macos/Runner/Release.entitlements index ee95ab7e..38da9a97 100644 --- a/packages/playground/macos/Runner/Release.entitlements +++ b/packages/playground/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/packages/playground/pubspec.yaml b/packages/playground/pubspec.yaml index 41a63f8b..374d4888 100644 --- a/packages/playground/pubspec.yaml +++ b/packages/playground/pubspec.yaml @@ -46,6 +46,9 @@ dependencies: ack_json_schema_builder: 1.0.0-beta.12 dartantic_ai: ^3.1.0 json_schema_builder: ^0.1.5 + # Filesystem persistence for the playground editor (desktop-only). + path_provider: ^2.1.4 + file_picker: ^8.1.2 dev_dependencies: flutter_test: @@ -55,6 +58,10 @@ dev_dependencies: flutter_lints: ^5.0.0 build_runner: ^2.5.4 ack_generator: 1.0.0-beta.12 + # Test-only: mock path_provider so the native deck file store can be exercised + # against a temp directory. + path_provider_platform_interface: ^2.1.2 + plugin_platform_interface: ^2.1.8 flutter: uses-material-design: true diff --git a/packages/playground/test/core/data/data_sources/deck_file_store_io_test.dart b/packages/playground/test/core/data/data_sources/deck_file_store_io_test.dart new file mode 100644 index 00000000..8d7ca545 --- /dev/null +++ b/packages/playground/test/core/data/data_sources/deck_file_store_io_test.dart @@ -0,0 +1,82 @@ +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:playground/core/data/data_sources/deck_file_store.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +/// Points `path_provider` at a temp directory so the native store's fixed +/// `~/Documents/SuperDeck/` folder resolves under the test sandbox. +class _FakePathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + _FakePathProvider(this.root); + + final String root; + + @override + Future getApplicationDocumentsPath() async => root; + + @override + Future getApplicationSupportPath() async => root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory temp; + late NativeDeckFileStore store; + + setUp(() async { + temp = await Directory.systemTemp.createTemp('deck_file_store_test'); + PathProviderPlatform.instance = _FakePathProvider(temp.path); + store = NativeDeckFileStore(); + }); + + tearDown(() async { + if (await temp.exists()) await temp.delete(recursive: true); + }); + + test('decksDirectoryPath creates ~/Documents/SuperDeck', () async { + final dir = await store.decksDirectoryPath(); + expect(dir, p.join(temp.path, 'SuperDeck')); + expect(await Directory(dir).exists(), isTrue); + }); + + test('write then read round-trips content', () async { + final path = p.join(temp.path, 'nested', 'deck.md'); + await store.write(path, '# Hello'); + expect(await store.exists(path), isTrue); + expect(await store.read(path), '# Hello'); + }); + + test('read throws DeckFileReadException for a missing file', () async { + expect( + () => store.read(p.join(temp.path, 'missing.md')), + throwsA(isA()), + ); + }); + + test('createDeck writes .md and returns its path', () async { + final path = await store.createDeck('talk', content: '# Talk'); + expect(path, p.join(temp.path, 'SuperDeck', 'talk.md')); + expect(await File(path).readAsString(), '# Talk'); + }); + + test('createDeck appends .md and strips directory components', () async { + final path = await store.createDeck('../evil/name', content: 'x'); + expect(p.basename(path), 'name.md'); + expect(p.dirname(path), p.join(temp.path, 'SuperDeck')); + }); + + test('createDeck throws on a name collision', () async { + await store.createDeck('talk', content: 'first'); + expect( + () => store.createDeck('talk', content: 'second'), + throwsA(isA()), + ); + }); +} diff --git a/packages/playground/test/features/editor/domain/stores/deck_file_controller_test.dart b/packages/playground/test/features/editor/domain/stores/deck_file_controller_test.dart new file mode 100644 index 00000000..7feb03ef --- /dev/null +++ b/packages/playground/test/features/editor/domain/stores/deck_file_controller_test.dart @@ -0,0 +1,293 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:playground/core/data/data_sources/deck_file_store.dart'; +import 'package:playground/features/editor/domain/stores/deck_file_controller.dart'; +import 'package:playground/features/editor/utils/markdown_editor.dart'; + +import '../../../../helpers/fake_deck_file_store.dart'; + +/// Editor stub that echoes reloads back into the controller, exactly as the +/// real `TextEditorController` forwards document changes to `handleEditorChange` +/// — so the loop-prevention paths are exercised. +class FakeMarkdownEditor implements MarkdownEditor { + FakeMarkdownEditor(this.controller); + + final DeckFileController controller; + final List replaced = []; + + @override + void replaceMarkdown(String markdown) { + replaced.add(markdown); + controller.handleEditorChange(markdown); + } +} + +void main() { + const debounce = Duration(milliseconds: 5); + Future afterDebounce() => + Future.delayed(const Duration(milliseconds: 20)); + + DeckFileController newController( + FakeDeckFileStore store, + FakeAppSettingsStore settings, + ) { + final controller = DeckFileController( + store: store, + settings: settings, + autoSaveDebounce: debounce, + ); + addTearDown(controller.dispose); + return controller; + } + + group('initialize', () { + test('creates and binds a default deck on first run', () async { + final store = FakeDeckFileStore(); + final settings = FakeAppSettingsStore(); + final controller = newController(store, settings); + + await controller.initialize(); + + final expectedPath = p.join('/decks', 'untitled.md'); + expect(controller.boundPath, expectedPath); + expect(controller.isBound, isTrue); + expect(controller.content, kStarterDeckMarkdown); + expect(store.files[expectedPath], kStarterDeckMarkdown); + expect(settings.path, expectedPath, reason: 'remembers last-opened'); + }); + + test('reopens the remembered file when it exists', () async { + final store = FakeDeckFileStore(); + final settings = FakeAppSettingsStore(); + store.files['/somewhere/deck.md'] = '# Remembered'; + settings.path = '/somewhere/deck.md'; + final controller = newController(store, settings); + + await controller.initialize(); + + expect(controller.boundPath, '/somewhere/deck.md'); + expect(controller.content, '# Remembered'); + }); + + test('falls back to default when the remembered file is missing', () async { + final store = FakeDeckFileStore(); + final settings = FakeAppSettingsStore(); + settings.path = '/gone/deck.md'; + final controller = newController(store, settings); + + await controller.initialize(); + + expect(controller.boundPath, p.join('/decks', 'untitled.md')); + }); + }); + + group('auto-save', () { + test('debounced write persists the latest edit', () async { + final store = FakeDeckFileStore(); + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final path = controller.boundPath!; + final writesBefore = store.writeCount; + + controller.handleEditorChange('# Edited'); + controller.handleEditorChange('# Edited twice'); + await afterDebounce(); + + expect(store.files[path], '# Edited twice'); + expect(store.writeCount, writesBefore + 1, reason: 'coalesced'); + }); + + test('skips writing when content is unchanged (echo)', () async { + final store = FakeDeckFileStore(); + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final writesBefore = store.writeCount; + + controller.handleEditorChange(controller.content); + await afterDebounce(); + + expect(store.writeCount, writesBefore); + }); + + test('reverting to synced content mid-debounce cancels the stale save', + () async { + final store = FakeDeckFileStore(); + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final path = controller.boundPath!; + final synced = controller.content; + final writesBefore = store.writeCount; + + // Type an edit, then revert back to the synced content before the + // debounce fires. The pending save of the intermediate edit must not run. + controller.handleEditorChange('# Intermediate'); + controller.handleEditorChange(synced); + await afterDebounce(); + + expect(store.files[path], synced, reason: 'disk keeps the synced content'); + expect(store.writeCount, writesBefore, reason: 'stale save was cancelled'); + }); + + test('a failed write does not mark content as synced', () async { + final store = FakeDeckFileStore(); + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final path = controller.boundPath!; + final editor = FakeMarkdownEditor(controller); + controller.attachEditor(editor); + + // Auto-save fails: nothing reaches disk and the synced marker must not + // advance to the unwritten content. + store.failWrites = true; + controller.handleEditorChange('# Never persisted'); + await afterDebounce(); + + // A later external edit must still be detected as external (not mistaken + // for our own write) and reloaded into the editor. + store.failWrites = false; + await store.externalWrite(path, '# External'); + + expect(editor.replaced, ['# External']); + expect(controller.content, '# External'); + }); + }); + + group('external changes', () { + test('self-write is filtered out (no reload)', () async { + final store = FakeDeckFileStore(); + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final editor = FakeMarkdownEditor(controller); + controller.attachEditor(editor); + final path = controller.boundPath!; + + controller.handleEditorChange('# Ours'); + await afterDebounce(); + // Watcher fires for our own write: disk content matches last synced. + await store.externalWrite(path, '# Ours'); + + expect(editor.replaced, isEmpty); + }); + + test( + 'external edit auto-reloads into the editor without looping', + () async { + final store = FakeDeckFileStore(); + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final editor = FakeMarkdownEditor(controller); + controller.attachEditor(editor); + final path = controller.boundPath!; + final writesBefore = store.writeCount; + + await store.externalWrite(path, '# From another app'); + + expect(editor.replaced, ['# From another app']); + expect(controller.content, '# From another app'); + await afterDebounce(); + expect( + store.writeCount, + writesBefore, + reason: 'reload must not trigger a re-save loop', + ); + }, + ); + + test('deletion unbinds, warns, and keeps in-memory content', () async { + final store = FakeDeckFileStore(); + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final path = controller.boundPath!; + controller.handleEditorChange('# In progress'); + await afterDebounce(); + + await store.externalDelete(path); + + expect(controller.isBound, isFalse); + expect(controller.status, DeckBindingStatus.unbound); + expect(controller.warning, isNotNull); + expect(controller.content, '# In progress'); + + // Further edits are not persisted and the file is not resurrected. + final writesBefore = store.writeCount; + controller.handleEditorChange('# More'); + await afterDebounce(); + expect(store.writeCount, writesBefore); + expect(store.files.containsKey(path), isFalse); + }); + }); + + group('newDeck', () { + test('creates, seeds the starter, and rebinds', () async { + final store = FakeDeckFileStore(); + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final editor = FakeMarkdownEditor(controller); + controller.attachEditor(editor); + + await controller.newDeck('talk'); + + final path = p.join('/decks', 'talk.md'); + expect(controller.boundPath, path); + expect(controller.content, kStarterDeckMarkdown); + expect(store.files[path], kStarterDeckMarkdown); + expect(editor.replaced, [kStarterDeckMarkdown]); + }); + + test('throws DeckNameCollisionException on a name clash', () async { + final store = FakeDeckFileStore(); + store.files[p.join('/decks', 'talk.md')] = 'existing'; + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + + expect( + () => controller.newDeck('talk'), + throwsA(isA()), + ); + }); + }); + + group('openDeck', () { + test('binds the picked file', () async { + final store = FakeDeckFileStore(); + store.files['/elsewhere/other.md'] = '# Other'; + store.pickResult = '/elsewhere/other.md'; + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final editor = FakeMarkdownEditor(controller); + controller.attachEditor(editor); + + await controller.openDeck(); + + expect(controller.boundPath, '/elsewhere/other.md'); + expect(controller.content, '# Other'); + expect(editor.replaced, ['# Other']); + }); + + test('cancelled picker is a no-op', () async { + final store = FakeDeckFileStore(); + store.pickResult = null; + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final original = controller.boundPath; + + await controller.openDeck(); + + expect(controller.boundPath, original); + }); + + test('read failure warns and keeps the current file', () async { + final store = FakeDeckFileStore(); + store.pickResult = '/elsewhere/missing.md'; // not in files → read throws + final controller = newController(store, FakeAppSettingsStore()); + await controller.initialize(); + final original = controller.boundPath; + + await controller.openDeck(); + + expect(controller.boundPath, original); + expect(controller.warning, isNotNull); + expect(controller.isBound, isTrue); + }); + }); +} diff --git a/packages/playground/test/features/editor/editor_page_test.dart b/packages/playground/test/features/editor/editor_page_test.dart index 3603ae1d..66edcd76 100644 --- a/packages/playground/test/features/editor/editor_page_test.dart +++ b/packages/playground/test/features/editor/editor_page_test.dart @@ -8,20 +8,33 @@ import 'package:playground/features/editor/presentation/pages/editor_page.dart'; import 'package:playground/features/editor/presentation/widgets/customization_sidebar.dart'; import 'package:playground/features/editor/presentation/widgets/preview_sidebar.dart'; +import '../../helpers/fake_deck_file_store.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); Widget app() { + // In-memory store/settings so the editor's file-backed bootstrap resolves + // without touching disk or spinning a real file watcher. return MaterialApp.router( routerConfig: createRouter(), - builder: (context, child) => _Theme(child: AppProviders(child: child!)), + builder: (context, child) => _Theme( + child: AppProviders( + deckFileStore: FakeDeckFileStore(), + appSettingsStore: FakeAppSettingsStore(), + child: child!, + ), + ), ); } testWidgets('editor route builds with its sidebars', (tester) async { await tester.pumpWidget(app()); + // Let the async bootstrap (seed the in-memory default deck) resolve so the + // editor mounts past its loading spinner. await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); expect(find.byType(EditorPage), findsOneWidget); expect(find.byType(PreviewSidebar), findsOneWidget); diff --git a/packages/playground/test/helpers/fake_deck_file_store.dart b/packages/playground/test/helpers/fake_deck_file_store.dart new file mode 100644 index 00000000..55e8ad9d --- /dev/null +++ b/packages/playground/test/helpers/fake_deck_file_store.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:path/path.dart' as p; +import 'package:playground/core/data/data_sources/app_settings_store.dart'; +import 'package:playground/core/data/data_sources/deck_file_store.dart'; + +/// In-memory [DeckFileStore] with a controllable watcher, so tests can simulate +/// external edits and deletions without touching the filesystem. +class FakeDeckFileStore extends DeckFileStore { + final Map files = {}; + final Map> _watchers = {}; + String decksDir = '/decks'; + String? pickResult; + int writeCount = 0; + + /// When true, [write] throws without touching [files] — simulates a disk + /// write that fails after the file already exists (permissions, full disk). + bool failWrites = false; + + @override + Future decksDirectoryPath() async => decksDir; + + @override + Future exists(String path) async => files.containsKey(path); + + @override + Future read(String path) async { + final content = files[path]; + if (content == null) throw DeckFileReadException(path, 'missing'); + return content; + } + + @override + Future write(String path, String content) async { + if (failWrites) throw Exception('write failed'); + writeCount++; + files[path] = content; + } + + @override + Future createDeck(String name, {required String content}) async { + final fileName = name.endsWith('.md') ? name : '$name.md'; + final path = p.join(decksDir, fileName); + if (files.containsKey(path)) { + throw DeckNameCollisionException(fileName); + } + files[path] = content; + return path; + } + + @override + Future pickDeckFile() async => pickResult; + + @override + Stream watch(String path) => _watchers + .putIfAbsent(path, () => StreamController.broadcast()) + .stream; + + /// Simulates an external tool rewriting [path], then fires the watcher. + Future externalWrite(String path, String content) async { + files[path] = content; + _watchers[path]?.add(null); + await _settle(); + } + + /// Simulates the bound file being deleted/moved, then fires the watcher. + Future externalDelete(String path) async { + files.remove(path); + _watchers[path]?.add(null); + await _settle(); + } + + Future _settle() => + Future.delayed(const Duration(milliseconds: 10)); +} + +class FakeAppSettingsStore extends AppSettingsStore { + String? path; + + @override + Future lastOpenedDeckPath() async => path; + + @override + Future setLastOpenedDeckPath(String value) async => path = value; +}