From 582bc439db71fae7040b5ecb77d2efc2ddb35744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Tue, 9 Jun 2026 14:25:35 +0000 Subject: [PATCH 1/4] ci: automate dev container build via devcontainer.json + workflow Adds .devcontainer/devcontainer.json that points to Dockerfile.dev so any devcontainer-aware tool (VS Code, Codespaces, etc.) can build the local dev environment directly from source. Adds a Forgejo workflow that rebuilds and pushes the image to codeberg.org/guettli/sharedinbox-dev (tagged both :latest and the short commit SHA) whenever Dockerfile.dev, the devcontainer config, or the workflow itself changes on main. This prevents the published image from silently drifting from its source. The workflow uses the built-in FORGEJO_TOKEN to log in to the Codeberg container registry - no extra secrets needed. Closes #552 Co-Authored-By: Claude Opus 4.7 (1M context) --- .devcontainer/devcontainer.json | 10 +++++ .forgejo/workflows/publish-dev-container.yml | 44 ++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .forgejo/workflows/publish-dev-container.yml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c3180d5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "name": "SharedInbox Dev", + "build": { + "dockerfile": "../Dockerfile.dev", + "context": ".." + }, + "workspaceFolder": "/src", + "workspaceMount": "source=${localWorkspaceFolder},target=/src,type=bind,consistency=cached", + "remoteUser": "ci" +} diff --git a/.forgejo/workflows/publish-dev-container.yml b/.forgejo/workflows/publish-dev-container.yml new file mode 100644 index 0000000..501835c --- /dev/null +++ b/.forgejo/workflows/publish-dev-container.yml @@ -0,0 +1,44 @@ +name: Publish Dev Container + +on: + push: + branches: [main] + paths: + - 'Dockerfile.dev' + - '.devcontainer/devcontainer.json' + - '.forgejo/workflows/publish-dev-container.yml' + workflow_dispatch: + +jobs: + publish: + name: Build & Push sharedinbox-dev + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + REGISTRY: codeberg.org + IMAGE: codeberg.org/guettli/sharedinbox-dev + + steps: + - uses: actions/checkout@v4 + + - name: Log in to Codeberg container registry + env: + FORGEJO_TOKEN: ${{ github.token }} + run: | + echo "$FORGEJO_TOKEN" \ + | docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin + + - name: Build image + run: | + SHORT_SHA="${GITHUB_SHA:0:7}" + docker build \ + -t "$IMAGE:latest" \ + -t "$IMAGE:$SHORT_SHA" \ + -f Dockerfile.dev \ + . + + - name: Push image + run: | + SHORT_SHA="${GITHUB_SHA:0:7}" + docker push "$IMAGE:latest" + docker push "$IMAGE:$SHORT_SHA" -- 2.52.0 From 0297701829680e44bd766b360234c067803df2d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Tue, 9 Jun 2026 21:31:45 +0200 Subject: [PATCH 2/4] ci: automate dev container build via devcontainer.json + workflow (#553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #552 ## Summary - Add `.devcontainer/devcontainer.json` pointing at `../Dockerfile.dev` so VS Code / Codespaces / any devcontainer-aware tool can build the dev environment directly from source. - Add `.forgejo/workflows/publish-dev-container.yml` that rebuilds `Dockerfile.dev` and pushes it to `codeberg.org/guettli/sharedinbox-dev` whenever `Dockerfile.dev`, the devcontainer config, or the workflow itself changes on `main`. The image is tagged both `:latest` and with the short commit SHA for pinnable references. - The workflow uses the built-in `FORGEJO_TOKEN` to log in to Codeberg's container registry — no extra secrets required. ## Notes - No existing references to `ghcr.io/guettli/sharedinbox-dev` were found in the repo, so issue step 3 (updating image references) is a no-op here. - `workflow_dispatch` is also enabled so the image can be rebuilt manually if needed. ## Verification - `python3 -c "import json; json.load(...)"` parses the devcontainer config. - `python3 -c "import yaml; yaml.safe_load(...)"` parses the workflow. - Triggers (paths filter) match the source files the issue identifies as drift risks. Co-authored-by: Thomas Güttler Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/553 --- .devcontainer/devcontainer.json | 10 +++++ .forgejo/workflows/publish-dev-container.yml | 44 ++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .forgejo/workflows/publish-dev-container.yml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c3180d5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "name": "SharedInbox Dev", + "build": { + "dockerfile": "../Dockerfile.dev", + "context": ".." + }, + "workspaceFolder": "/src", + "workspaceMount": "source=${localWorkspaceFolder},target=/src,type=bind,consistency=cached", + "remoteUser": "ci" +} diff --git a/.forgejo/workflows/publish-dev-container.yml b/.forgejo/workflows/publish-dev-container.yml new file mode 100644 index 0000000..501835c --- /dev/null +++ b/.forgejo/workflows/publish-dev-container.yml @@ -0,0 +1,44 @@ +name: Publish Dev Container + +on: + push: + branches: [main] + paths: + - 'Dockerfile.dev' + - '.devcontainer/devcontainer.json' + - '.forgejo/workflows/publish-dev-container.yml' + workflow_dispatch: + +jobs: + publish: + name: Build & Push sharedinbox-dev + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + REGISTRY: codeberg.org + IMAGE: codeberg.org/guettli/sharedinbox-dev + + steps: + - uses: actions/checkout@v4 + + - name: Log in to Codeberg container registry + env: + FORGEJO_TOKEN: ${{ github.token }} + run: | + echo "$FORGEJO_TOKEN" \ + | docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin + + - name: Build image + run: | + SHORT_SHA="${GITHUB_SHA:0:7}" + docker build \ + -t "$IMAGE:latest" \ + -t "$IMAGE:$SHORT_SHA" \ + -f Dockerfile.dev \ + . + + - name: Push image + run: | + SHORT_SHA="${GITHUB_SHA:0:7}" + docker push "$IMAGE:latest" + docker push "$IMAGE:$SHORT_SHA" -- 2.52.0 From de2b9d22b439fd2245ba69360d357dade26cbce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Wed, 10 Jun 2026 13:13:28 +0200 Subject: [PATCH 3/4] fix(ci): stop gradle daemon between flutter build apk and assembleAndroidTest (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The Firebase Test Lab job (issue #549) failed because `flutter build apk --debug --no-pub` spawned a Gradle daemon, whose journal-cache lock file was left on the persistent Dagger `gradle-cache` mount after the `WithExec` container was torn down. The next exec, `./gradlew --no-daemon app:assembleAndroidTest`, then timed out after 60s waiting for that stale lock: ``` > Timeout waiting to lock journal cache (/home/ci/.gradle/caches/journal-1). It is currently in use by another process. Owner PID: 88 Our PID: 53 ``` The pre-existing `--no-daemon` only prevented stale daemon-registry reuse, not stale lock files. **Fix:** chain `./gradlew --stop` into the first `WithExec` so the daemon shuts down gracefully and releases its locks before Dagger snapshots the layer. ## Test plan - [ ] CI passes - [ ] Manually re-run the Firebase Tests workflow (`workflow_dispatch`) and confirm the Gradle journal-lock error no longer appears Closes #549 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Till Düßmann (Claude agent) Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/554 --- ci/main.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ci/main.go b/ci/main.go index 53f6867..cf9d9b2 100644 --- a/ci/main.go +++ b/ci/main.go @@ -814,7 +814,14 @@ func (m *Ci) DeployApk( // Returns a flat directory with app-debug.apk and app-debug-androidTest.apk. func (m *Ci) BuildAndroidDebugApks() *dagger.Directory { built := m.firebaseBase(). - WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}). + // `flutter build apk` spawns a Gradle daemon. When this WithExec ends the + // container is torn down and the daemon is killed, but its journal-cache + // lock file on the persistent gradle-cache volume keeps its dead PID — the + // next gradlew invocation then times out waiting for that lock. `gradlew + // --stop` shuts the daemon down gracefully so the lock is released before + // Dagger snapshots the layer. + WithExec([]string{"/bin/bash", "-c", + `flutter build apk --debug --no-pub && (cd android && ./gradlew --stop)`}). WithWorkdir("/src/android"). // --no-daemon avoids connecting to a stale daemon whose registry file was // preserved in the Dagger layer snapshot but whose process no longer exists. -- 2.52.0 From f1f7de7b4d555492a985ea062d31ba742592b928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Wed, 10 Jun 2026 13:15:48 +0200 Subject: [PATCH 4/4] feat(undo-log): hyperlink email rows in Undo Log Detail (#474) (#547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Each email row in the **Undo Log Detail** "Emails" section is now tappable. - Tapping resolves the email via `EmailRepository.findEmailByMessageId(accountId, messageId)` and navigates to its **current** location, so the link survives the move/snooze that changed its IMAP UID. - If the email has no Message-ID, or no row matches the lookup (e.g. hard-deleted), a SnackBar explains the situation instead of navigating. A `chevron_right` trailing icon was added to signal the rows are now navigable. Closes #474 ## Test plan - [x] New widget test `test/widget/undo_log_detail_screen_test.dart` covers: - tap on a row whose lookup hits → navigates to `/accounts//mailboxes//emails/` with the **current** mailbox/id - tap when lookup returns `null` → "Email no longer exists" SnackBar, no navigation - tap when the original row has no Message-ID → "no Message-ID" SnackBar, no navigation Co-authored-by: guettli Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/547 --- lib/ui/screens/undo_log_detail_screen.dart | 49 +++++- test/widget/undo_log_detail_screen_test.dart | 176 +++++++++++++++++++ 2 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 test/widget/undo_log_detail_screen_test.dart diff --git a/lib/ui/screens/undo_log_detail_screen.dart b/lib/ui/screens/undo_log_detail_screen.dart index d690c37..7060d6e 100644 --- a/lib/ui/screens/undo_log_detail_screen.dart +++ b/lib/ui/screens/undo_log_detail_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; @@ -93,7 +94,9 @@ class UndoLogDetailScreen extends ConsumerWidget { style: theme.textTheme.bodySmall, ), ), - ...action.originalEmails.map((email) => _EmailTile(email: email)), + ...action.originalEmails.map( + (email) => _EmailTile(email: email, accountId: action.accountId), + ), ], ), ); @@ -120,13 +123,14 @@ class _SectionHeader extends StatelessWidget { } } -class _EmailTile extends StatelessWidget { - const _EmailTile({required this.email}); +class _EmailTile extends ConsumerWidget { + const _EmailTile({required this.email, required this.accountId}); final Email email; + final String accountId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final sender = email.from.isNotEmpty ? (email.from.first.name ?? email.from.first.email) : '(Unknown Sender)'; @@ -134,6 +138,43 @@ class _EmailTile extends StatelessWidget { leading: const Icon(Icons.email_outlined), title: Text(email.subject ?? '(No Subject)'), subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openEmail(context, ref), + ); + } + + Future _openEmail(BuildContext context, WidgetRef ref) async { + final messageId = email.messageId; + final messenger = ScaffoldMessenger.of(context); + if (messageId == null) { + messenger.showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text('Cannot locate this email — no Message-ID.'), + ), + ); + return; + } + final found = await ref + .read(emailRepositoryProvider) + .findEmailByMessageId(accountId, messageId); + if (!context.mounted) return; + if (found == null) { + messenger.showSnackBar( + const SnackBar( + duration: Duration(seconds: 5), + content: Text( + 'Email no longer exists at its previous location. ' + 'Use Undo to restore it.', + ), + ), + ); + return; + } + context.go( + '/accounts/$accountId' + '/mailboxes/${Uri.encodeComponent(found.mailboxPath)}' + '/emails/${Uri.encodeComponent(found.id)}', ); } } diff --git a/test/widget/undo_log_detail_screen_test.dart b/test/widget/undo_log_detail_screen_test.dart new file mode 100644 index 0000000..eaa9cd9 --- /dev/null +++ b/test/widget/undo_log_detail_screen_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/core/models/undo_action.dart'; +import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart'; + +import 'helpers.dart'; + +// FakeEmailRepository subclass that returns a pre-configured email from +// findEmailByMessageId, so the tap handler in UndoLogDetailScreen can be +// exercised without a real database. +class _LookupEmailRepository extends FakeEmailRepository { + _LookupEmailRepository(this._lookup); + + final Email? _lookup; + + @override + Future findEmailByMessageId( + String accountId, + String messageId, + ) async => + _lookup; +} + +UndoAction _action({ + required List originalEmails, + String accountId = 'acc-1', +}) => + UndoAction( + id: 'undo-1', + accountId: accountId, + type: UndoType.move, + emailIds: originalEmails.map((e) => e.id).toList(), + sourceMailboxPath: 'INBOX', + destinationMailboxPath: 'Archive', + originalEmails: originalEmails, + timestamp: DateTime(2024, 6), + ); + +Email _emailWith({ + String id = 'acc-1:42', + String mailboxPath = 'INBOX', + String? messageId = '', +}) => + Email( + id: id, + accountId: 'acc-1', + mailboxPath: mailboxPath, + uid: 42, + subject: 'Hello world', + receivedAt: DateTime(2024, 6), + sentAt: DateTime(2024, 6), + from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], + to: const [EmailAddress(email: 'alice@example.com')], + cc: const [], + isSeen: false, + isFlagged: false, + hasAttachment: false, + messageId: messageId, + ); + +// Builds a minimal app whose initial location is the undo log detail screen +// for [action]. A placeholder email-detail route records its visit so the +// test can assert which path the tap navigated to. +Widget _buildApp({ + required UndoAction action, + required FakeEmailRepository emailRepo, + ValueNotifier? lastEmailRoute, +}) { + final router = GoRouter( + initialLocation: '/undo-detail', + routes: [ + GoRoute( + path: '/undo-detail', + builder: (ctx, state) => UndoLogDetailScreen(action: action), + ), + GoRoute( + path: '/accounts/:accountId/mailboxes/:mailboxPath/emails/:emailId', + builder: (ctx, state) { + lastEmailRoute?.value = state.uri.toString(); + return const Scaffold(body: Text('email-detail-route')); + }, + ), + ], + ); + + return ProviderScope( + overrides: [ + emailRepositoryProvider.overrideWithValue(emailRepo), + ], + child: MaterialApp.router(routerConfig: router), + ); +} + +void main() { + group('UndoLogDetailScreen email row tap', () { + testWidgets('navigates to the current location returned by lookup', ( + tester, + ) async { + // Original row recorded INBOX/42; after the move it now lives in + // Archive with a fresh UID — the lookup is what bridges that gap. + final original = _emailWith(); + final current = _emailWith(id: 'acc-1:77', mailboxPath: 'Archive'); + final lastRoute = ValueNotifier(null); + + await tester.pumpWidget( + _buildApp( + action: _action(originalEmails: [original]), + emailRepo: _LookupEmailRepository(current), + lastEmailRoute: lastRoute, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hello world')); + await tester.pumpAndSettle(); + + expect(find.text('email-detail-route'), findsOneWidget); + expect( + lastRoute.value, + '/accounts/acc-1/mailboxes/Archive/emails/acc-1%3A77', + ); + }); + + testWidgets('shows snackbar when lookup returns null', (tester) async { + final original = _emailWith(); + final lastRoute = ValueNotifier(null); + + await tester.pumpWidget( + _buildApp( + action: _action(originalEmails: [original]), + emailRepo: _LookupEmailRepository(null), + lastEmailRoute: lastRoute, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hello world')); + await tester.pump(); + + expect( + find.textContaining('Email no longer exists'), + findsOneWidget, + ); + expect(lastRoute.value, isNull); + expect(find.text('email-detail-route'), findsNothing); + }); + + testWidgets('shows snackbar when email has no Message-ID', (tester) async { + final original = _emailWith(messageId: null); + final lastRoute = ValueNotifier(null); + + await tester.pumpWidget( + _buildApp( + action: _action(originalEmails: [original]), + // Lookup would succeed if called, but with no Message-ID the + // tap handler must short-circuit before reaching it. + emailRepo: _LookupEmailRepository(_emailWith()), + lastEmailRoute: lastRoute, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Hello world')); + await tester.pump(); + + expect(find.textContaining('no Message-ID'), findsOneWidget); + expect(lastRoute.value, isNull); + expect(find.text('email-detail-route'), findsNothing); + }); + }); +} -- 2.52.0