Compare commits

...
Author SHA1 Message Date
agentloop 2d28a4be34 plan: refresh plan for issue #555 2026-06-10 12:46:01 +00:00
Bot of Thomas Güttlerandguettli f1f7de7b4d feat(undo-log): hyperlink email rows in Undo Log Detail (#474) (#547)
## 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/<acc>/mailboxes/<encoded>/emails/<encoded>` 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 <guettli@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/547
2026-06-10 13:15:48 +02:00
de2b9d22b4 fix(ci): stop gradle daemon between flutter build apk and assembleAndroidTest (#554)
## 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) <tilldu@googlemail.com>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/554
2026-06-10 13:13:28 +02:00
0297701829 ci: automate dev container build via devcontainer.json + workflow (#553)
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 <tilldu@googlemail.com>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/553
2026-06-09 21:31:45 +02:00
ee238b85c7 fix(ci): set loop/code label on Firebase test failure issues (#551)
Closes #550

## Summary

When Firebase instrumented tests fail in the nightly run, the workflow opens a tracking issue. It currently tags it with the legacy `Ready` label, which is not part of the current agent loop. Switch the label to `loop/code` so the coding agent picks it up automatically and the error gets fixed.

## Change

- `.forgejo/workflows/firebase-tests.yml`: set `loop/code` instead of `Ready` on the created failure issue.

## Test plan

- [ ] Wait for next scheduled (or manually dispatched) Firebase test failure and confirm the created issue carries the `loop/code` label.

Co-authored-by: guettlibot <tilldu@googlemail.com>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/551
2026-06-09 16:08:19 +02:00
Thomas Güttler f0eff7dc7c Merge branch 'drop-nix' 2026-06-08 22:44:55 +02:00
8ea5237991 fix(detail): auto-dismiss "Load remote images" snack bar (#548)
## Summary

- The "Load remote images" snack bar in single-mail view (and the analogous thread view) never disappeared on its own — the user had to interact with it.
- Flutter's `SnackBar` defaults to `persist: true` whenever an `action` is provided (see `flutter/lib/src/material/snack_bar.dart`: `persist = persist ?? action != null`), which short-circuits the duration-based dismiss timer in `ScaffoldMessengerState.build`:

  ```dart
  _snackBarTimer = Timer(snackBar.duration, () {
    if (snackBar.persist) return;          // <-- here
    hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
  });
  ```

  So the explicit `duration: 3s` was set, but the "View" action made the snack bar persistent and the timer's callback returned early.
- Pass `persist: false` explicitly on both snack bars so the 3-second timer fires and the snack bar slides away on its own, while the "View" action button still works to navigate to the trusted-senders settings.

## Test plan

- [x] Added widget regression test in `test/widget/email_detail_screen_test.dart` (`Load remote images snack bar auto-dismisses after 3 seconds`).
- [x] Added analogous test in `test/widget/thread_detail_screen_test.dart`.
- [x] `task test-widget` — all 174 widget tests pass.
- [x] `scripts/run_unit_tests.sh` — all 552 unit tests pass.
- [x] `fvm dart analyze --fatal-infos` on changed files — no issues.
- [x] `fvm dart format` — no diffs.
- [ ] Manual: open a single mail with HTML body from an untrusted sender; tap "Load remote images"; verify the snack bar appears, images load, and the snack bar disappears after ~3 seconds while the "View" action button still navigates to `/accounts/trusted-senders` when tapped.

Closes #484

Co-authored-by: Agentloop Bot <agentloop-bot@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/548
2026-06-08 21:59:49 +02:00
11 changed files with 440 additions and 6 deletions
+10
View File
@@ -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"
}
+1 -1
View File
@@ -135,7 +135,7 @@ jobs:
repo_labels = api_get("/labels")
label_map = {l["name"]: l["id"] for l in repo_labels}
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
label_ids = [label_map["loop/code"]] if "loop/code" in label_map else []
title = "Firebase Tests failed — find root cause and fix"
body = (
@@ -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"
+46
View File
@@ -0,0 +1,46 @@
## Background
PR #554 fixed the Firebase Test Lab build by chaining `./gradlew --stop` after `flutter build apk` so the daemon releases its journal-cache lock before Dagger snapshots the layer. This issue asks whether that daemon is useful in the first place — and if not, suppress it instead of cleaning up after it.
## Answer to the question
**The daemon provides zero benefit in this CI path, and there is no "existing daemon" for it to reuse.**
- Each Dagger `WithExec` runs in its own ephemeral container. When `flutter build apk` returns, the container is torn down and the daemon process is killed. The only thing that survives is on-disk state on the `gradle-cache` volume mount (`/home/ci/.gradle`), including the journal-cache lock file naming the now-dead PID.
- The next exec (`./gradlew --no-daemon app:assembleAndroidTest`) runs in a fresh container with PID 1 = gradlew. There is no live daemon process to connect to, and `--no-daemon` would refuse to anyway — but it still has to *acquire* the journal-cache lock and times out because the stale lock file is still there.
- So the daemon's one job (persist between invocations to amortize JVM startup) cannot happen here: invocation boundaries are container boundaries. The daemon only exists long enough to perform a single build, then is killed — i.e., it behaves like `--no-daemon` but with extra cleanup hazards.
## Proposed change
Prevent the daemon from spawning in CI instead of stopping it afterward. This makes `flutter build apk` behave the same way `./gradlew --no-daemon app:assembleAndroidTest` already does, removes the `bash -c "... && ./gradlew --stop"` wrapper, and eliminates the class of stale-lock failures rather than the one observed instance.
### `ci/main.go`
1. In `firebaseBase()` (and `androidBase()` for consistency — same cache mount, same risk surface), add an env var that disables the Gradle daemon for every invocation in those containers:
```go
WithEnvVariable("GRADLE_OPTS", "-Dorg.gradle.daemon=false")
```
Scoped to CI containers only, so local `./gradlew` still gets a daemon.
2. In `BuildAndroidDebugApks()`, revert the first `WithExec` to its pre-#554 form:
```go
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
```
Drop the `bash -c "... && ./gradlew --stop"` wrapper and the comment block explaining the lock-file workaround.
3. In the second `WithExec`, drop the `--no-daemon` flag since `GRADLE_OPTS` now covers it. Update the adjacent comment (currently explaining stale-daemon-registry reuse) to point at the env-var approach instead, or remove it.
### Why `GRADLE_OPTS=-Dorg.gradle.daemon=false` and not `gradle.properties`
`android/gradle.properties` is checked into the repo and read on every developer machine. Setting `org.gradle.daemon=false` there would slow down local Android builds (every `flutter run` would pay full JVM startup). The env var keeps the change inside the CI module.
## Verification
- `dagger call test-android-firebase ...` (or whatever invocation #554 was validated against): build APK + androidTest APK succeed end-to-end with no `--stop` step and no journal-lock timeout.
- Confirm the Gradle log from `flutter build apk` shows `Daemon will be stopped at the end of the build` / no "Starting a Gradle Daemon" line, or at minimum that no daemon process is left running before container teardown.
- `assembleAndroidTest` continues to succeed on a warm `gradle-cache` (dependencies still cached on the volume; only the daemon state is disabled).
## Out of scope
- Touching `gradle.properties` (developer-facing, daemon still wanted locally).
- Changing the `gradle-cache` mount to non-persistent — defeats the dependency-caching benefit, which is the actual reason the mount exists.
+8 -1
View File
@@ -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.
+4
View File
@@ -239,6 +239,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
// SnackBar defaults to persist=true when an action
// is set, which disables the auto-dismiss timer.
// Explicitly opt back into duration-based dismiss.
persist: false,
content: const Text(
'Images will be loaded automatically for this sender.',
),
+4
View File
@@ -214,6 +214,10 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
// SnackBar defaults to persist=true when an
// action is set, which disables auto-dismiss.
// Explicitly opt into duration-based dismiss.
persist: false,
content: const Text(
'Images will be loaded automatically for this sender.',
),
+45 -4
View File
@@ -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<void> _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)}',
);
}
}
+48
View File
@@ -582,6 +582,54 @@ void main() {
expect(find.textContaining('Structure not available'), findsOneWidget);
});
testWidgets(
'Load remote images snack bar auto-dismisses after 3 seconds',
(tester) async {
const body = EmailBody(
emailId: 'acc-1:42',
htmlBody: '<p>Hello <img src="https://example.com/x.png"/></p>',
attachments: [],
);
await tester.pumpWidget(
buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(body: body),
),
);
await tester.pumpAndSettle();
// The "Load remote images" button is visible because the sender is
// not yet trusted.
expect(find.text('Load remote images'), findsOneWidget);
await tester.tap(find.text('Load remote images'));
// Settle the snack bar enter animation and the setState rebuild
// that swaps in the image-loading WebView.
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
// Snack bar must be visible.
expect(
find.text('Images will be loaded automatically for this sender.'),
findsOneWidget,
);
// After 3 seconds (the snack bar's duration) plus the reverse
// animation, the snack bar must be gone.
// Regression test for #484: SnackBar with an action defaults to
// persist=true, which disables auto-dismiss — explicit persist:false
// restores duration-based dismissal.
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(
find.text('Images will be loaded automatically for this sender.'),
findsNothing,
);
},
);
});
}
@@ -249,5 +249,59 @@ void main() {
expect(find.text('Body content here'), findsOneWidget);
});
testWidgets(
'Load remote images snack bar auto-dismisses after 3 seconds',
(tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
htmlBody:
'<p>Hi <img src="https://example.com/x.png"/></p>',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Load remote images'), findsOneWidget);
await tester.tap(find.text('Load remote images'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(
find.text('Images will be loaded automatically for this sender.'),
findsOneWidget,
);
// Regression test for #484: SnackBar with an action defaults to
// persist=true, which disables auto-dismiss — explicit persist:false
// restores duration-based dismissal.
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
expect(
find.text('Images will be loaded automatically for this sender.'),
findsNothing,
);
},
);
});
}
@@ -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<Email?> findEmailByMessageId(
String accountId,
String messageId,
) async =>
_lookup;
}
UndoAction _action({
required List<Email> 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 = '<msg-1@example.com>',
}) =>
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<String?>? 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<String?>(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<String?>(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<String?>(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);
});
});
}