diff --git a/CLAUDE.md b/CLAUDE.md index 88e4cd0..5ebf7b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ Two-crate workspace (`resolver = "2"`, edition 2021) plus a Tauri app: - **onyx-core** — Pure Rust library. Storage trait with `FileSystemStorage` implementation, `TaskRepository` (main API), data models, config, error types. No CLI/UI dependencies. - **onyx-cli** — CLI frontend using clap. Commands are in `src/commands/` (init, workspace, list, task, group). Output formatting in `src/output.rs`. - **apps/tauri/** — Tauri v2 GUI. Svelte 5 frontend in `src/`, Rust backend in `src-tauri/` with Tauri commands that call into `onyx-core`. +- **apps/flutter/** — Flutter GUI. Dart frontend in `lib/src/`, Rust backend in `rust/` via flutter_rust_bridge FFI into `onyx-core`. ### Key patterns @@ -55,11 +56,11 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). - **Kebab menus**: Tasks, lists, and workspaces all use kebab → submenu pattern for delete. - **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning). -### Current state (2026-03-30) +### Current state (2026-03-31) - **Phase 1** (Core + CLI): Complete -- **Phase 2** (WebDAV sync): Backend done, CLI done, GUI partially wired (empty credentials issue) -- **Phase 3** (GUI MVP): In progress — core task CRUD working, UI polished with animations +- **Phase 2** (WebDAV sync): Backend done, CLI done, GUI wired (settings auto-populates credentials) +- **Phase 3** (GUI MVP): Near complete — core features working, both Tauri and Flutter GUIs maintained ### GUI features done @@ -72,17 +73,23 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). - Workspace switcher drop-up with add/remove - Dark mode (GNOME-style neutral grays, cyan-blue accent) - Completed tasks section with animated show/hide +- Due date picker/editor (DateTimePicker in new task + task detail) +- Move task between lists (kebab menu → "Move to..." submenu) +- List rename (inline input via list kebab menu) +- Group-by-due-date toggle per list (list kebab menu) +- Keyboard shortcuts (Escape priority chain: settings → detail → drawer → menus) +- WebDAV setup flow (settings auto-populates URL/credentials from config + keychain) +- File watcher (notify crate, 500ms debounce, auto-reloads on external changes) +- Setup screen with window dragging + "Open Existing Folder" option -### GUI features NOT yet done (CLI has these) +### GUI features NOT yet done -- Due date editing (model supports it, not exposed in UI) -- WebDAV setup flow (GUI passes empty credentials) - Push-only / pull-only sync modes -- Sync status view +- Sync status view/indicators - Workspace retarget/migrate -- Group-by-due-date toggle - Subtask hierarchy (data model exists, not used anywhere) -- List/workspace rename +- Search/filter tasks +- Desktop packaging (Windows, Linux, macOS) ## Roadmap diff --git a/Cargo.lock b/Cargo.lock index d88b146..194cd5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,40 +104,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "onyx-cli" -version = "0.1.0" -dependencies = [ - "anyhow", - "onyx-core", - "chrono", - "clap", - "colored", - "fs_extra", - "rpassword", - "tokio", - "uuid", -] - -[[package]] -name = "onyx-core" -version = "0.1.0" -dependencies = [ - "chrono", - "directories", - "keyring", - "quick-xml", - "reqwest", - "serde", - "serde_json", - "serde_yaml", - "sha2", - "tempfile", - "tokio", - "uuid", - "wiremock", -] - [[package]] name = "bitflags" version = "2.11.0" @@ -1002,6 +968,40 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onyx-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "colored", + "fs_extra", + "onyx-core", + "rpassword", + "tokio", + "uuid", +] + +[[package]] +name = "onyx-core" +version = "0.1.0" +dependencies = [ + "chrono", + "directories", + "keyring", + "quick-xml", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tempfile", + "tokio", + "uuid", + "wiremock", +] + [[package]] name = "option-ext" version = "0.2.0" diff --git a/PLAN.md b/PLAN.md index ed76fcc..6d17dde 100644 --- a/PLAN.md +++ b/PLAN.md @@ -715,18 +715,19 @@ WorkspaceConfig { - [x] Settings popup overlay (WebDAV config, dark mode toggle) - [x] Dark mode (GNOME-style neutral theme, cyan-blue accent) - [x] Animated completed section show/hide -- [ ] Move task between lists (needs `move_task(from_list, to_list, task_id)` added to onyx-core + Tauri command, then wire into task detail kebab menu) +- [x] Move task between lists (kebab menu → "Move to..." submenu in task detail view) - [ ] Optional time on due dates (backend `due_date` is `DateTime` — needs a separate `due_time` field or a nullable time component so date-only tasks don't default to midnight; currently the GUI uses `hours == 0 && minutes == 0` as a heuristic for "no time set" which breaks for actual midnight times) -- [ ] Due date picker/editor (backend supports it, needs date input in new task toast + inline editing) -- [ ] WebDAV setup flow with credentials (settings panel has fields, triggerSync needs to pull creds from config) -- [ ] List/workspace rename (needs `rename_list` added to onyx-core first) -- [ ] Keyboard shortcuts (Escape to close drawers/menus, tab navigation, Enter behaviors) +- [x] Due date picker/editor (DateTimePicker component in both new task toast + task detail view) +- [x] WebDAV setup flow with credentials (settings auto-populates URL/username/password from config + keychain on open) +- [x] List rename (inline input via list kebab menu in drawer) +- [x] Keyboard shortcuts (Escape closes settings → detail → drawer → menus in priority order) - [ ] Sync status indicators (per workspace) - [ ] Push/pull sync mode selection -- [ ] Group-by-due-date toggle per list +- [x] Group-by-due-date toggle per list (checkmark toggle in list kebab menu) - [ ] Subtask hierarchy (data model exists, needs UI) - [ ] Search/filter tasks - [ ] Desktop packaging (Windows, Linux, macOS) +- [x] File watcher (notify crate, 500ms debounce, auto-reloads UI on external file changes) ### Deliverables diff --git a/apps/flutter/.gitignore b/apps/flutter/.gitignore index 3820a95..89f241f 100644 --- a/apps/flutter/.gitignore +++ b/apps/flutter/.gitignore @@ -27,12 +27,21 @@ migrate_working_dir/ **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ +.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ /build/ /coverage/ +# Generated plugin registrants +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + # Symbolication related app.*.symbols diff --git a/apps/flutter/lib/src/rust/api.dart b/apps/flutter/lib/src/rust/api.dart index 957f28f..0982289 100644 --- a/apps/flutter/lib/src/rust/api.dart +++ b/apps/flutter/lib/src/rust/api.dart @@ -6,7 +6,7 @@ import 'frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `config_to_dto`, `ensure_repo`, `task_to_dto` +// These functions are ignored because they are not marked as `pub`: `config_to_dto`, `ensure_repo`, `mute_watcher`, `task_to_dto` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `AppState` Future getConfig() => RustLib.instance.api.crateApiGetConfig(); @@ -63,6 +63,33 @@ Future reorderTask({ newPosition: newPosition, ); +Future moveTask({ + required String fromListId, + required String toListId, + required String taskId, +}) => RustLib.instance.api.crateApiMoveTask( + fromListId: fromListId, + toListId: toListId, + taskId: taskId, +); + +Future renameList({required String listId, required String newName}) => + RustLib.instance.api.crateApiRenameList(listId: listId, newName: newName); + +Future setGroupByDueDate({ + required String listId, + required bool enabled, +}) => RustLib.instance.api.crateApiSetGroupByDueDate( + listId: listId, + enabled: enabled, +); + +Future getGroupByDueDate({required String listId}) => + RustLib.instance.api.crateApiGetGroupByDueDate(listId: listId); + +Future> watchWorkspaceChanges({required String path}) => + RustLib.instance.api.crateApiWatchWorkspaceChanges(path: path); + Future greet({required String name}) => RustLib.instance.api.crateApiGreet(name: name); diff --git a/apps/flutter/lib/src/rust/frb_generated.dart b/apps/flutter/lib/src/rust/frb_generated.dart index 372955b..831031d 100644 --- a/apps/flutter/lib/src/rust/frb_generated.dart +++ b/apps/flutter/lib/src/rust/frb_generated.dart @@ -64,7 +64,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.11.1'; @override - int get rustContentHash => 1511441297; + int get rustContentHash => -75020133; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -97,6 +97,8 @@ abstract class RustLibApi extends BaseApi { Future crateApiGetConfig(); + Future crateApiGetGroupByDueDate({required String listId}); + Future> crateApiGetLists(); Future crateApiGreet({required String name}); @@ -105,8 +107,19 @@ abstract class RustLibApi extends BaseApi { Future> crateApiListTasks({required String listId}); + Future crateApiMoveTask({ + required String fromListId, + required String toListId, + required String taskId, + }); + Future crateApiRemoveWorkspace({required String name}); + Future crateApiRenameList({ + required String listId, + required String newName, + }); + Future crateApiReorderTask({ required String listId, required String taskId, @@ -115,6 +128,11 @@ abstract class RustLibApi extends BaseApi { Future crateApiSetCurrentWorkspace({required String name}); + Future crateApiSetGroupByDueDate({ + required String listId, + required bool enabled, + }); + Future crateApiToggleTask({ required String listId, required String taskId, @@ -124,6 +142,8 @@ abstract class RustLibApi extends BaseApi { required String listId, required TaskDto task, }); + + Future> crateApiWatchWorkspaceChanges({required String path}); } class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { @@ -321,6 +341,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiGetConfigConstMeta => const TaskConstMeta(debugName: "get_config", argNames: []); + @override + Future crateApiGetGroupByDueDate({required String listId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 7, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiGetGroupByDueDateConstMeta, + argValues: [listId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiGetGroupByDueDateConstMeta => const TaskConstMeta( + debugName: "get_group_by_due_date", + argNames: ["listId"], + ); + @override Future> crateApiGetLists() { return handler.executeNormal( @@ -330,7 +380,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 7, + funcId: 8, port: port_, ); }, @@ -358,7 +408,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 8, + funcId: 9, port: port_, ); }, @@ -386,7 +436,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 9, + funcId: 10, port: port_, ); }, @@ -414,7 +464,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 10, + funcId: 11, port: port_, ); }, @@ -432,6 +482,42 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiListTasksConstMeta => const TaskConstMeta(debugName: "list_tasks", argNames: ["listId"]); + @override + Future crateApiMoveTask({ + required String fromListId, + required String toListId, + required String taskId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(fromListId, serializer); + sse_encode_String(toListId, serializer); + sse_encode_String(taskId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 12, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiMoveTaskConstMeta, + argValues: [fromListId, toListId, taskId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiMoveTaskConstMeta => const TaskConstMeta( + debugName: "move_task", + argNames: ["fromListId", "toListId", "taskId"], + ); + @override Future crateApiRemoveWorkspace({required String name}) { return handler.executeNormal( @@ -442,7 +528,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 11, + funcId: 13, port: port_, ); }, @@ -460,6 +546,40 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiRemoveWorkspaceConstMeta => const TaskConstMeta(debugName: "remove_workspace", argNames: ["name"]); + @override + Future crateApiRenameList({ + required String listId, + required String newName, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + sse_encode_String(newName, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 14, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiRenameListConstMeta, + argValues: [listId, newName], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiRenameListConstMeta => const TaskConstMeta( + debugName: "rename_list", + argNames: ["listId", "newName"], + ); + @override Future crateApiReorderTask({ required String listId, @@ -476,7 +596,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 12, + funcId: 15, port: port_, ); }, @@ -506,7 +626,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 13, + funcId: 16, port: port_, ); }, @@ -527,6 +647,40 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["name"], ); + @override + Future crateApiSetGroupByDueDate({ + required String listId, + required bool enabled, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(listId, serializer); + sse_encode_bool(enabled, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 17, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_String, + ), + constMeta: kCrateApiSetGroupByDueDateConstMeta, + argValues: [listId, enabled], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetGroupByDueDateConstMeta => const TaskConstMeta( + debugName: "set_group_by_due_date", + argNames: ["listId", "enabled"], + ); + @override Future crateApiToggleTask({ required String listId, @@ -541,7 +695,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 14, + funcId: 18, port: port_, ); }, @@ -575,7 +729,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 15, + funcId: 19, port: port_, ); }, @@ -595,6 +749,54 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["listId", "task"], ); + @override + Future> crateApiWatchWorkspaceChanges({ + required String path, + }) async { + final sink = RustStreamSink(); + await handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(path, serializer); + sse_encode_StreamSink_unit_Sse(sink, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 20, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiWatchWorkspaceChangesConstMeta, + argValues: [path, sink], + apiImpl: this, + ), + ); + return sink.stream; + } + + TaskConstMeta get kCrateApiWatchWorkspaceChangesConstMeta => + const TaskConstMeta( + debugName: "watch_workspace_changes", + argNames: ["path", "sink"], + ); + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AnyhowException(raw as String); + } + + @protected + RustStreamSink dco_decode_StreamSink_unit_Sse(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + @protected String dco_decode_String(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -720,6 +922,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_String(deserializer); + return AnyhowException(inner); + } + + @protected + RustStreamSink sse_decode_StreamSink_unit_Sse( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + throw UnimplementedError('Unreachable ()'); + } + @protected String sse_decode_String(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -886,6 +1103,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getInt32(); } + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.message, serializer); + } + + @protected + void sse_encode_StreamSink_unit_Sse( + RustStreamSink self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String( + self.setupAndSerialize( + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + ), + serializer, + ); + } + @protected void sse_encode_String(String self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/apps/flutter/lib/src/rust/frb_generated.io.dart b/apps/flutter/lib/src/rust/frb_generated.io.dart index fdc7102..98ae350 100644 --- a/apps/flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/flutter/lib/src/rust/frb_generated.io.dart @@ -18,6 +18,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { required super.portManager, }); + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_unit_Sse(dynamic raw); + @protected String dco_decode_String(dynamic raw); @@ -63,6 +69,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected WorkspaceEntry dco_decode_workspace_entry(dynamic raw); + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_unit_Sse( + SseDeserializer deserializer, + ); + @protected String sse_decode_String(SseDeserializer deserializer); @@ -113,6 +127,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_i_32(SseDeserializer deserializer); + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ); + + @protected + void sse_encode_StreamSink_unit_Sse( + RustStreamSink self, + SseSerializer serializer, + ); + @protected void sse_encode_String(String self, SseSerializer serializer); diff --git a/apps/flutter/lib/src/rust/frb_generated.web.dart b/apps/flutter/lib/src/rust/frb_generated.web.dart index 19a95b9..3d0c0b4 100644 --- a/apps/flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/flutter/lib/src/rust/frb_generated.web.dart @@ -20,6 +20,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { required super.portManager, }); + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + RustStreamSink dco_decode_StreamSink_unit_Sse(dynamic raw); + @protected String dco_decode_String(dynamic raw); @@ -65,6 +71,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected WorkspaceEntry dco_decode_workspace_entry(dynamic raw); + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + RustStreamSink sse_decode_StreamSink_unit_Sse( + SseDeserializer deserializer, + ); + @protected String sse_decode_String(SseDeserializer deserializer); @@ -115,6 +129,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_i_32(SseDeserializer deserializer); + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ); + + @protected + void sse_encode_StreamSink_unit_Sse( + RustStreamSink self, + SseSerializer serializer, + ); + @protected void sse_encode_String(String self, SseSerializer serializer); diff --git a/apps/flutter/lib/src/screens/tasks_screen.dart b/apps/flutter/lib/src/screens/tasks_screen.dart index b926746..f26fbc5 100644 --- a/apps/flutter/lib/src/screens/tasks_screen.dart +++ b/apps/flutter/lib/src/screens/tasks_screen.dart @@ -329,6 +329,8 @@ class _TasksScreenState extends State with SingleTickerProviderStat _closeDrawer(); }, onDelete: () => state.deleteList(list.id), + onRename: (newName) => state.renameList(list.id, newName), + onToggleGroupByDueDate: () => state.setGroupByDueDate(list.id, !list.groupByDueDate), ), // New list button / input Padding( @@ -651,8 +653,10 @@ class _ListTile extends StatefulWidget { final bool isActive; final VoidCallback onTap; final VoidCallback onDelete; + final void Function(String newName) onRename; + final VoidCallback onToggleGroupByDueDate; - const _ListTile({required this.list, required this.isActive, required this.onTap, required this.onDelete}); + const _ListTile({required this.list, required this.isActive, required this.onTap, required this.onDelete, required this.onRename, required this.onToggleGroupByDueDate}); @override State<_ListTile> createState() => _ListTileState(); @@ -661,6 +665,38 @@ class _ListTile extends StatefulWidget { class _ListTileState extends State<_ListTile> { bool _hovering = false; + void _showRenameDialog(BuildContext context) { + final controller = TextEditingController(text: widget.list.title); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Rename list'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(hintText: 'List name'), + onSubmitted: (value) { + Navigator.pop(ctx); + if (value.trim().isNotEmpty && value.trim() != widget.list.title) { + widget.onRename(value.trim()); + } + }, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + TextButton( + onPressed: () { + Navigator.pop(ctx); + final name = controller.text.trim(); + if (name.isNotEmpty && name != widget.list.title) widget.onRename(name); + }, + child: const Text('Rename'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -674,6 +710,20 @@ class _ListTileState extends State<_ListTile> { context: context, position: RelativeRect.fromLTRB(details.globalPosition.dx, details.globalPosition.dy, 0, 0), items: [ + PopupMenuItem( + onTap: () => _showRenameDialog(context), + child: const Text('Rename', style: TextStyle(fontSize: 13)), + ), + PopupMenuItem( + onTap: widget.onToggleGroupByDueDate, + child: Row( + children: [ + Expanded(child: Text('Group by due date', style: const TextStyle(fontSize: 13))), + if (widget.list.groupByDueDate) + const Icon(Icons.check, size: 16, color: AppTheme.primary), + ], + ), + ), PopupMenuItem( onTap: widget.onDelete, child: const Text('Delete', style: TextStyle(color: AppTheme.danger, fontSize: 13)), diff --git a/apps/flutter/lib/src/state/app_state.dart b/apps/flutter/lib/src/state/app_state.dart index 9c5ba65..41c213f 100644 --- a/apps/flutter/lib/src/state/app_state.dart +++ b/apps/flutter/lib/src/state/app_state.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import '../rust/api.dart' as api; @@ -8,6 +9,7 @@ class AppState extends ChangeNotifier { String? activeListId; List tasks = []; bool darkMode = true; + StreamSubscription? _watcherSub; bool syncing = false; String? error; @@ -26,12 +28,22 @@ class AppState extends ChangeNotifier { api.TaskDto? get selectedTask => selectedTaskId == null ? null : tasks.cast().firstWhere((t) => t?.id == selectedTaskId, orElse: () => null); + Future _startWatcher(String path) async { + _watcherSub?.cancel(); + try { + final stream = await api.watchWorkspaceChanges(path: path); + _watcherSub = stream.listen((_) => loadLists()); + } catch (_) {} + } + Future loadConfig() async { try { config = await api.getConfig(); if (hasWorkspace) { screen = 'tasks'; await loadLists(); + final ws = config!.workspaces.firstWhere((w) => w.name == config!.currentWorkspace); + _startWatcher(ws.path); } else { screen = 'setup'; } @@ -48,6 +60,7 @@ class AppState extends ChangeNotifier { await api.addWorkspace(name: name, path: path); config = await api.getConfig(); await loadLists(); + _startWatcher(path); screen = 'tasks'; error = null; } catch (e) { @@ -62,6 +75,8 @@ class AppState extends ChangeNotifier { config = await api.getConfig(); activeListId = null; await loadLists(); + final ws = config!.workspaces.firstWhere((w) => w.name == name); + _startWatcher(ws.path); error = null; } catch (e) { error = e.toString(); @@ -199,6 +214,39 @@ class AppState extends ChangeNotifier { notifyListeners(); } + Future moveTask(String taskId, String targetListId) async { + if (activeListId == null) return; + try { + await api.moveTask(fromListId: activeListId!, toListId: targetListId, taskId: taskId); + tasks = tasks.where((t) => t.id != taskId).toList(); + if (selectedTaskId == taskId) selectedTaskId = null; + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future renameList(String listId, String newName) async { + try { + await api.renameList(listId: listId, newName: newName); + await loadLists(); + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + + Future setGroupByDueDate(String listId, bool enabled) async { + try { + await api.setGroupByDueDate(listId: listId, enabled: enabled); + await loadLists(); + if (listId == activeListId) await loadTasks(); + } catch (e) { + error = e.toString(); + } + notifyListeners(); + } + Future deleteTask(String taskId) async { if (activeListId == null) return; try { diff --git a/apps/flutter/lib/src/widgets/task_detail_view.dart b/apps/flutter/lib/src/widgets/task_detail_view.dart index a8d51a9..aacc06e 100644 --- a/apps/flutter/lib/src/widgets/task_detail_view.dart +++ b/apps/flutter/lib/src/widgets/task_detail_view.dart @@ -118,6 +118,39 @@ class _TaskDetailViewState extends State with SingleTickerProvid return '$day, ${pad(local.day)}/${pad(local.month)}$timePart'; } + void _showMoveToSheet(BuildContext context, AppState state) { + final otherLists = state.lists.where((l) => l.id != state.activeListId).toList(); + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Move to...', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + for (final list in otherLists) + ListTile( + title: Text(list.title, style: const TextStyle(fontSize: 14)), + onTap: () { + Navigator.pop(context); + state.moveTask(widget.task.id, list.id); + state.selectTask(null); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -317,6 +350,15 @@ class _TaskDetailViewState extends State with SingleTickerProvid state.selectTask(null); }, ), + if (state.lists.where((l) => l.id != state.activeListId).isNotEmpty) + _KebabMenuItem( + icon: Icons.drive_file_move_outline, + label: 'Move to...', + onTap: () { + setState(() => _showMenu = false); + _showMoveToSheet(context, state); + }, + ), _KebabMenuItem( icon: Icons.delete_outline, label: 'Delete', diff --git a/apps/flutter/rust/Cargo.lock b/apps/flutter/rust/Cargo.lock index c8f4b3c..6b6df3f 100644 --- a/apps/flutter/rust/Cargo.lock +++ b/apps/flutter/rust/Cargo.lock @@ -109,32 +109,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "onyx-core" -version = "0.1.0" -dependencies = [ - "chrono", - "directories", - "keyring", - "quick-xml", - "reqwest", - "serde", - "serde_json", - "serde_yaml", - "sha2", - "tokio", - "uuid", -] - -[[package]] -name = "onyx-flutter" -version = "0.1.0" -dependencies = [ - "onyx-core", - "chrono", - "flutter_rust_bridge", - "once_cell", - "uuid", -] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" @@ -394,6 +372,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -457,6 +452,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -884,6 +888,35 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -931,6 +964,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -964,9 +1017,18 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1026,10 +1088,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1064,6 +1167,36 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "onyx-core" +version = "0.1.0" +dependencies = [ + "chrono", + "directories", + "keyring", + "quick-xml", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "uuid", +] + +[[package]] +name = "onyx-flutter" +version = "0.1.0" +dependencies = [ + "chrono", + "flutter_rust_bridge", + "notify", + "notify-debouncer-mini", + "once_cell", + "onyx-core", + "uuid", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1099,7 +1232,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1128,6 +1261,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1291,7 +1430,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", ] [[package]] @@ -1398,6 +1546,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1445,6 +1606,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1457,7 +1627,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -1470,7 +1640,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1653,6 +1823,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1786,7 +1969,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -1901,6 +2084,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2021,7 +2214,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -2056,6 +2249,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2404,7 +2606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/apps/flutter/rust/Cargo.toml b/apps/flutter/rust/Cargo.toml index 6f2f7d3..f0630d9 100644 --- a/apps/flutter/rust/Cargo.toml +++ b/apps/flutter/rust/Cargo.toml @@ -12,3 +12,5 @@ onyx-core = { path = "../../../crates/onyx-core" } uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } once_cell = "1" +notify = "7" +notify-debouncer-mini = "0.5" diff --git a/apps/flutter/rust/src/api.rs b/apps/flutter/rust/src/api.rs index 20cda7e..389cb3e 100644 --- a/apps/flutter/rust/src/api.rs +++ b/apps/flutter/rust/src/api.rs @@ -1,12 +1,15 @@ use std::path::PathBuf; use std::sync::Mutex; +use std::time::{Duration, Instant}; +use flutter_rust_bridge::frb; +use notify_debouncer_mini::{new_debouncer, DebouncedEventKind}; use once_cell::sync::Lazy; use uuid::Uuid; use onyx_core::{ config::{AppConfig, WorkspaceConfig}, - models::{Task, TaskList, TaskStatus}, + models::{Task, TaskStatus}, repository::TaskRepository, }; @@ -158,6 +161,7 @@ pub fn get_lists() -> Result, String> { pub fn create_list(name: String) -> Result { let mut s = STATE.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(); let list = s.repo.as_mut().unwrap().create_list(name).map_err(|e| e.to_string())?; Ok(TaskListDto { id: list.id.to_string(), @@ -171,6 +175,7 @@ pub fn create_list(name: String) -> Result { pub fn delete_list(list_id: String) -> Result<(), String> { let mut s = STATE.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(); let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; s.repo.as_mut().unwrap().delete_list(id).map_err(|e| e.to_string()) } @@ -188,6 +193,7 @@ pub fn list_tasks(list_id: String) -> Result, String> { pub fn create_task(list_id: String, title: String, description: String) -> Result { let mut s = STATE.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(); let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let mut task = Task::new(title); if !description.is_empty() { @@ -200,6 +206,7 @@ pub fn create_task(list_id: String, title: String, description: String) -> Resul pub fn update_task(list_id: String, task: TaskDto) -> Result<(), String> { let mut s = STATE.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task.id).map_err(|e| e.to_string())?; @@ -218,6 +225,7 @@ pub fn update_task(list_id: String, task: TaskDto) -> Result<(), String> { pub fn delete_task(list_id: String, task_id: String) -> Result<(), String> { let mut s = STATE.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; s.repo.as_mut().unwrap().delete_task(lid, tid).map_err(|e| e.to_string()) @@ -226,6 +234,7 @@ pub fn delete_task(list_id: String, task_id: String) -> Result<(), String> { pub fn toggle_task(list_id: String, task_id: String) -> Result { let mut s = STATE.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; let repo = s.repo.as_mut().unwrap(); @@ -241,6 +250,7 @@ pub fn toggle_task(list_id: String, task_id: String) -> Result pub fn reorder_task(list_id: String, task_id: String, new_position: u32) -> Result<(), String> { let mut s = STATE.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; s.repo @@ -250,6 +260,79 @@ pub fn reorder_task(list_id: String, task_id: String, new_position: u32) -> Resu .map_err(|e| e.to_string()) } +// ── Move / rename / grouping ─────────────────────────────────────── + +pub fn move_task(from_list_id: String, to_list_id: String, task_id: String) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(); + let from = Uuid::parse_str(&from_list_id).map_err(|e| e.to_string())?; + let to = Uuid::parse_str(&to_list_id).map_err(|e| e.to_string())?; + let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; + s.repo.as_mut().unwrap().move_task(from, to, tid).map_err(|e| e.to_string()) +} + +pub fn rename_list(list_id: String, new_name: String) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(); + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo.as_mut().unwrap().rename_list(id, new_name).map_err(|e| e.to_string()) +} + +pub fn set_group_by_due_date(list_id: String, enabled: bool) -> Result<(), String> { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(); + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo.as_mut().unwrap().set_group_by_due_date(id, enabled).map_err(|e| e.to_string()) +} + +pub fn get_group_by_due_date(list_id: String) -> Result { + let mut s = STATE.lock().unwrap(); + ensure_repo(&mut s)?; + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo.as_ref().unwrap().get_group_by_due_date(id).map_err(|e| e.to_string()) +} + +// ── File watcher ─────────────────────────────────────────────────── + +static WATCHER: Mutex>> = + Mutex::new(None); + +static LAST_WRITE: Mutex> = Mutex::new(None); + +fn mute_watcher() { + *LAST_WRITE.lock().unwrap() = Some(Instant::now()); +} + +#[frb(stream_dart_await)] +pub fn watch_workspace_changes(path: String, sink: crate::frb_generated::StreamSink<()>) { + let debouncer = new_debouncer( + Duration::from_millis(500), + move |events: Result, notify::Error>| { + let Ok(events) = events else { return }; + let has_data_change = events.iter().any(|e| { + if e.kind != DebouncedEventKind::Any { return false; } + let p = e.path.to_string_lossy(); + p.ends_with(".md") || p.ends_with(".json") + }); + if !has_data_change { return; } + if let Some(t) = *LAST_WRITE.lock().unwrap() { + if t.elapsed() < Duration::from_secs(1) { return; } + } + let _ = sink.add(()); + }, + ); + match debouncer { + Ok(mut d) => { + let _ = d.watcher().watch(&PathBuf::from(&path), notify::RecursiveMode::Recursive); + *WATCHER.lock().unwrap() = Some(d); + } + Err(e) => eprintln!("Failed to start file watcher: {e}"), + } +} + // ── Test function ─────────────────────────────────────────────────── pub fn greet(name: String) -> String { diff --git a/apps/flutter/rust/src/frb_generated.rs b/apps/flutter/rust/src/frb_generated.rs index 15d71c3..b780f8b 100644 --- a/apps/flutter/rust/src/frb_generated.rs +++ b/apps/flutter/rust/src/frb_generated.rs @@ -37,7 +37,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1511441297; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -75020133; // Section: executor @@ -247,6 +247,39 @@ fn wire__crate__api__get_config_impl( }, ) } +fn wire__crate__api__get_group_by_due_date_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_group_by_due_date", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::get_group_by_due_date(api_list_id)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__get_lists_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -378,6 +411,42 @@ fn wire__crate__api__list_tasks_impl( }, ) } +fn wire__crate__api__move_task_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "move_task", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_from_list_id = ::sse_decode(&mut deserializer); + let api_to_list_id = ::sse_decode(&mut deserializer); + let api_task_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = + crate::api::move_task(api_from_list_id, api_to_list_id, api_task_id)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__remove_workspace_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -411,6 +480,40 @@ fn wire__crate__api__remove_workspace_impl( }, ) } +fn wire__crate__api__rename_list_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rename_list", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + let api_new_name = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::rename_list(api_list_id, api_new_name)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__reorder_task_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -480,6 +583,40 @@ fn wire__crate__api__set_current_workspace_impl( }, ) } +fn wire__crate__api__set_group_by_due_date_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_group_by_due_date", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_list_id = ::sse_decode(&mut deserializer); + let api_enabled = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::set_group_by_due_date(api_list_id, api_enabled)?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__api__toggle_task_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -548,9 +685,64 @@ fn wire__crate__api__update_task_impl( }, ) } +fn wire__crate__api__watch_workspace_changes_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "watch_workspace_changes", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_path = ::sse_decode(&mut deserializer); + let api_sink = + >::sse_decode( + &mut deserializer, + ); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::watch_workspace_changes(api_path, api_sink); + })?; + Ok(output_ok) + })()) + } + }, + ) +} // Section: dart2rust +impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner); + } +} + +impl SseDecode for StreamSink<(), flutter_rust_bridge::for_generated::SseCodec> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return StreamSink::deserialize(inner); + } +} + impl SseDecode for String { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -736,15 +928,20 @@ fn pde_ffi_dispatcher_primary_impl( 4 => wire__crate__api__delete_list_impl(port, ptr, rust_vec_len, data_len), 5 => wire__crate__api__delete_task_impl(port, ptr, rust_vec_len, data_len), 6 => wire__crate__api__get_config_impl(port, ptr, rust_vec_len, data_len), - 7 => wire__crate__api__get_lists_impl(port, ptr, rust_vec_len, data_len), - 8 => wire__crate__api__greet_impl(port, ptr, rust_vec_len, data_len), - 9 => wire__crate__api__init_workspace_impl(port, ptr, rust_vec_len, data_len), - 10 => wire__crate__api__list_tasks_impl(port, ptr, rust_vec_len, data_len), - 11 => wire__crate__api__remove_workspace_impl(port, ptr, rust_vec_len, data_len), - 12 => wire__crate__api__reorder_task_impl(port, ptr, rust_vec_len, data_len), - 13 => wire__crate__api__set_current_workspace_impl(port, ptr, rust_vec_len, data_len), - 14 => wire__crate__api__toggle_task_impl(port, ptr, rust_vec_len, data_len), - 15 => wire__crate__api__update_task_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__api__get_group_by_due_date_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__get_lists_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__greet_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__init_workspace_impl(port, ptr, rust_vec_len, data_len), + 11 => wire__crate__api__list_tasks_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__api__move_task_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__api__remove_workspace_impl(port, ptr, rust_vec_len, data_len), + 14 => wire__crate__api__rename_list_impl(port, ptr, rust_vec_len, data_len), + 15 => wire__crate__api__reorder_task_impl(port, ptr, rust_vec_len, data_len), + 16 => wire__crate__api__set_current_workspace_impl(port, ptr, rust_vec_len, data_len), + 17 => wire__crate__api__set_group_by_due_date_impl(port, ptr, rust_vec_len, data_len), + 18 => wire__crate__api__toggle_task_impl(port, ptr, rust_vec_len, data_len), + 19 => wire__crate__api__update_task_impl(port, ptr, rust_vec_len, data_len), + 20 => wire__crate__api__watch_workspace_changes_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -839,6 +1036,20 @@ impl flutter_rust_bridge::IntoIntoDart for crate::ap } } +impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(format!("{:?}", self), serializer); + } +} + +impl SseEncode for StreamSink<(), flutter_rust_bridge::for_generated::SseCodec> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("") + } +} + impl SseEncode for String { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/apps/flutter/windows/flutter/generated_plugin_registrant.cc b/apps/flutter/windows/flutter/generated_plugin_registrant.cc index c6fe39a..e69de29 100644 --- a/apps/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/apps/flutter/windows/flutter/generated_plugin_registrant.cc @@ -1,17 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); - WindowManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowManagerPlugin")); -} diff --git a/apps/flutter/windows/flutter/generated_plugin_registrant.h b/apps/flutter/windows/flutter/generated_plugin_registrant.h index dc139d8..e69de29 100644 --- a/apps/flutter/windows/flutter/generated_plugin_registrant.h +++ b/apps/flutter/windows/flutter/generated_plugin_registrant.h @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/flutter/windows/flutter/generated_plugins.cmake b/apps/flutter/windows/flutter/generated_plugins.cmake index 5e3bc3d..e69de29 100644 --- a/apps/flutter/windows/flutter/generated_plugins.cmake +++ b/apps/flutter/windows/flutter/generated_plugins.cmake @@ -1,25 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - screen_retriever_windows - window_manager -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index 2bab9cb..0b0814b 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -94,39 +94,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "onyx-core" -version = "0.1.0" -dependencies = [ - "chrono", - "directories", - "keyring", - "quick-xml 0.36.2", - "reqwest 0.12.28", - "serde", - "serde_json", - "serde_yaml", - "sha2", - "tokio", - "uuid", -] - -[[package]] -name = "onyx-tauri" -version = "0.1.0" -dependencies = [ - "onyx-core", - "chrono", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-os", - "tokio", - "uuid", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -842,6 +809,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -912,6 +890,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -1661,6 +1648,35 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1786,6 +1802,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -1859,7 +1895,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -1981,6 +2020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2060,6 +2100,46 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaa5a66d07ed97dce782be94dcf5ab4d1b457f4243f7566c7557f15cabc8c799" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2299,6 +2379,41 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "onyx-core" +version = "0.1.0" +dependencies = [ + "chrono", + "directories", + "keyring", + "quick-xml 0.36.2", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "uuid", +] + +[[package]] +name = "onyx-tauri" +version = "0.1.0" +dependencies = [ + "chrono", + "notify", + "notify-debouncer-mini", + "onyx-core", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-os", + "tokio", + "uuid", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2364,7 +2479,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2580,6 +2695,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -2933,6 +3054,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -3612,7 +3742,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -4131,6 +4261,19 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index be4977a..3752fda 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -23,6 +23,8 @@ onyx-core = { path = "../../../crates/onyx-core" } tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } +notify = "7" +notify-debouncer-mini = "0.5" [package.metadata.tauri] diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 85c95c2..fc7d3da 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ use std::path::PathBuf; use std::sync::Mutex; +use std::time::Instant; +use notify_debouncer_mini::{new_debouncer, DebouncedEventKind}; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{Emitter, Manager, State}; use uuid::Uuid; use onyx_core::{ @@ -13,6 +15,13 @@ use onyx_core::{ webdav, }; +/// Active file watcher stored globally so it lives for the app lifetime. +static WATCHER: Mutex>> = + Mutex::new(None); + +/// Shared mute timestamp — set before writes, checked by the watcher. +static LAST_WRITE: Mutex> = Mutex::new(None); + /// Shared application state behind a mutex. struct AppState { config: AppConfig, @@ -43,6 +52,11 @@ impl From for SyncResult { } } +/// Suppress file watcher events for the next second (call before writes). +fn mute_watcher(_state: &mut AppState) { + *LAST_WRITE.lock().unwrap() = Some(Instant::now()); +} + /// Helper: get or open a TaskRepository for the current workspace. fn ensure_repo(state: &mut AppState) -> Result<(), String> { if state.repo.is_some() { @@ -151,6 +165,7 @@ fn create_list( ) -> Result { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); s.repo .as_mut() .unwrap() @@ -165,6 +180,7 @@ fn delete_list( ) -> Result<(), String> { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; s.repo .as_mut() @@ -199,6 +215,7 @@ fn create_task( ) -> Result { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let mut task = Task::new(title); if let Some(desc) = description.filter(|d| !d.is_empty()) { @@ -219,6 +236,7 @@ fn update_task( ) -> Result<(), String> { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; s.repo .as_mut() @@ -235,6 +253,7 @@ fn delete_task( ) -> Result<(), String> { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; s.repo @@ -252,6 +271,7 @@ fn toggle_task( ) -> Result { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; let repo = s.repo.as_mut().unwrap(); @@ -274,6 +294,7 @@ fn reorder_task( ) -> Result<(), String> { let mut s = state.lock().unwrap(); ensure_repo(&mut s)?; + mute_watcher(&mut s); let lid = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; s.repo @@ -283,6 +304,77 @@ fn reorder_task( .map_err(|e| e.to_string()) } +// ── Move / rename / grouping ──────────────────────────────────────── + +#[tauri::command] +fn move_task( + from_list_id: String, + to_list_id: String, + task_id: String, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = state.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(&mut s); + let from = Uuid::parse_str(&from_list_id).map_err(|e| e.to_string())?; + let to = Uuid::parse_str(&to_list_id).map_err(|e| e.to_string())?; + let tid = Uuid::parse_str(&task_id).map_err(|e| e.to_string())?; + s.repo + .as_mut() + .unwrap() + .move_task(from, to, tid) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn rename_list( + list_id: String, + new_name: String, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = state.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(&mut s); + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo + .as_mut() + .unwrap() + .rename_list(id, new_name) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn set_group_by_due_date( + list_id: String, + enabled: bool, + state: State<'_, Mutex>, +) -> Result<(), String> { + let mut s = state.lock().unwrap(); + ensure_repo(&mut s)?; + mute_watcher(&mut s); + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo + .as_mut() + .unwrap() + .set_group_by_due_date(id, enabled) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn get_group_by_due_date( + list_id: String, + state: State<'_, Mutex>, +) -> Result { + let mut s = state.lock().unwrap(); + ensure_repo(&mut s)?; + let id = Uuid::parse_str(&list_id).map_err(|e| e.to_string())?; + s.repo + .as_ref() + .unwrap() + .get_group_by_due_date(id) + .map_err(|e| e.to_string()) +} + // ── Sync commands ──────────────────────────────────────────────────── #[tauri::command] @@ -348,6 +440,43 @@ async fn sync_workspace( Ok(result.into()) } +// ── File watcher ──────────────────────────────────────────────────── + +fn start_watcher(handle: tauri::AppHandle, path: PathBuf) { + let handle = handle.clone(); + let debouncer = new_debouncer( + std::time::Duration::from_millis(500), + move |events: Result, notify::Error>| { + let Ok(events) = events else { return }; + // Only care about data file changes + let has_data_change = events.iter().any(|e| { + if e.kind != DebouncedEventKind::Any { return false; } + let p = e.path.to_string_lossy(); + p.ends_with(".md") || p.ends_with(".json") + }); + if !has_data_change { return; } + // Skip if we wrote recently (self-change suppression) + if let Some(t) = *LAST_WRITE.lock().unwrap() { + if t.elapsed() < std::time::Duration::from_secs(1) { return; } + } + let _ = handle.emit("fs-changed", ()); + }, + ); + match debouncer { + Ok(mut d) => { + let _ = d.watcher().watch(&path, notify::RecursiveMode::Recursive); + *WATCHER.lock().unwrap() = Some(d); + } + Err(e) => eprintln!("Failed to start file watcher: {e}"), + } +} + +#[tauri::command] +fn watch_workspace(path: String, app_handle: tauri::AppHandle) -> Result<(), String> { + start_watcher(app_handle, PathBuf::from(path)); + Ok(()) +} + // ── App entry ──────────────────────────────────────────────────────── #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -360,6 +489,18 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_os::init()) .manage(Mutex::new(AppState { config, repo: None })) + .setup(|app| { + let handle = app.handle().clone(); + let state: State<'_, Mutex> = app.state(); + let workspace_path = { + let s = state.lock().unwrap(); + s.config.get_current_workspace().ok().map(|(_, ws)| ws.path.clone()) + }; + if let Some(path) = workspace_path { + start_watcher(handle, path); + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ get_config, save_config, @@ -376,11 +517,16 @@ pub fn run() { delete_task, toggle_task, reorder_task, + move_task, + rename_list, + set_group_by_due_date, + get_group_by_due_date, set_webdav_config, store_credentials, load_credentials, test_webdav_connection, sync_workspace, + watch_workspace, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/tauri/src/lib/components/TaskDetailView.svelte b/apps/tauri/src/lib/components/TaskDetailView.svelte index 06863d9..478dd35 100644 --- a/apps/tauri/src/lib/components/TaskDetailView.svelte +++ b/apps/tauri/src/lib/components/TaskDetailView.svelte @@ -14,10 +14,13 @@ let title = $state(task.title); let description = $state(task.description); let showMenu = $state(false); + let showMoveSubmenu = $state(false); let menuEl = $state(null); let showDatePicker = $state(false); let saveTimer: ReturnType; + let otherLists = $derived(app.lists.filter((l) => l.id !== app.activeListId)); + function handleHeaderMouseDown(e: MouseEvent) { if (e.button !== 0) return; if ((e.target as HTMLElement).closest("button")) return; @@ -126,6 +129,34 @@ {isCompleted ? "Restore task" : "Mark as completed"} + {#if otherLists.length > 0} +
+ + {#if showMoveSubmenu} +
+ {#each otherLists as list} + + {/each} +
+ {/if} +
+ {/if} + {/if} + + + {/if} + - +
+
+

Onyx

+

+ Create a new workspace or open an existing one. +

+ + + + + +
+ + +
- - -
- + +
+
+ or +
+
+ +
- -
diff --git a/apps/tauri/src/lib/screens/TasksScreen.svelte b/apps/tauri/src/lib/screens/TasksScreen.svelte index 11c9e68..92793e4 100644 --- a/apps/tauri/src/lib/screens/TasksScreen.svelte +++ b/apps/tauri/src/lib/screens/TasksScreen.svelte @@ -44,6 +44,8 @@ let completedVisible = $state(false); let listMenuId = $state(null); let wsMenuName = $state(null); + let renamingListId = $state(null); + let renameValue = $state(""); let dragId = $state(null); let dragOverId = $state(null); let resizing = $state(false); @@ -77,6 +79,40 @@ await app.deleteList(id); } + function startRenameList(id: string) { + listMenuId = null; + const list = app.lists.find(l => l.id === id); + if (!list) return; + renamingListId = id; + renameValue = list.title; + } + + async function handleRenameList() { + if (!renamingListId || !renameValue.trim()) { renamingListId = null; return; } + const list = app.lists.find(l => l.id === renamingListId); + if (renameValue.trim() !== list?.title) { + await app.renameList(renamingListId, renameValue.trim()); + } + renamingListId = null; + } + + async function handleToggleGroupByDueDate(id: string) { + listMenuId = null; + const list = app.lists.find(l => l.id === id); + if (!list) return; + await app.setGroupByDueDate(id, !list.group_by_due_date); + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key !== "Escape") return; + if (showSettings) { showSettings = false; return; } + if (selectedTaskId) { selectedTaskId = null; return; } + if (showDrawer) { closeDrawer(); return; } + if (listMenuId) { listMenuId = null; return; } + if (wsMenuName) { wsMenuName = null; return; } + if (showWorkspacePicker) { showWorkspacePicker = false; return; } + } + function handleDragStart(e: DragEvent, taskId: string) { dragId = taskId; if (e.dataTransfer) { @@ -148,6 +184,8 @@ let translateX = $derived(showDrawer ? '0' : '-80cqi'); + +
@@ -238,17 +276,30 @@
{#each app.lists as list (list.id)}
- + {#if renamingListId === list.id} +
+ { if (e.key === "Enter") handleRenameList(); if (e.key === "Escape") renamingListId = null; }} + onblur={handleRenameList} + autofocus + /> +
+ {:else} + + {/if}
{#if listMenuId === list.id} -
+
+ +