Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a70e8a011f | ||
|
|
968db75c69 | ||
|
|
d905cd653f | ||
|
|
e21cde0a3c | ||
|
|
50a6678ec2 | ||
|
|
91083218d4 | ||
|
|
adc4eb6f6d | ||
|
|
05d00bdf09 | ||
|
|
c45775be92 | ||
|
|
47fc534a8d | ||
|
|
a5928c1aa6 | ||
|
|
7f3cd43d6e |
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect Android and Linux changes
|
||||
id: diff
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
data = json.loads(r.read())
|
||||
runs = [
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
|
||||
if r.get("status") == "success"
|
||||
]
|
||||
print(runs[0].get("commit_sha") or "")
|
||||
except Exception as e:
|
||||
@@ -64,10 +64,17 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||
# when the parent is unavailable (initial commit, shallow clone).
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
# Diff from the last successfully deployed commit to catch all changes since
|
||||
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
|
||||
# LAST_DEPLOYED_SHA is unknown or not in local history.
|
||||
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
else
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
@@ -204,48 +211,6 @@ jobs:
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
publish-website:
|
||||
name: Publish Website Build History
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, deploy-playstore, deploy-apk]
|
||||
if: |
|
||||
always() &&
|
||||
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
label-deploy-health:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Monitor Agent Loop
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # every 2 hours
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Check Agent Loop Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check agent loop heartbeat
|
||||
run: python3 scripts/agent_loop.py monitor
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Update Website
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour on the hour
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
|
||||
@@ -8,46 +8,41 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
||||
|
||||
## Issue Label Workflow
|
||||
|
||||
We use issues, follow this label state machine:
|
||||
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
|
||||
|
||||
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
|
||||
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
||||
- **State/Ready** — Issue is approved and ready for implementation
|
||||
- **State/InProgress** — Set while an agent (or human) is actively working
|
||||
- **State/Question** — Agent hit a blocker or needs clarification
|
||||
| Label | Trigger | Outcome |
|
||||
|---|---|---|
|
||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
||||
|
||||
Full lifecycle:
|
||||
**State machine:**
|
||||
|
||||
```
|
||||
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
|
||||
State/Planned → State/Ready (manual: human reviews the plan and approves)
|
||||
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
|
||||
State/InProgress → closed (automated: after PR is merged and CI passes)
|
||||
any state → State/Question (automated or manual: when blocked)
|
||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
|
||||
loop/code → loop/code-in-progress → loop/code-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
```
|
||||
|
||||
List open issues ready to pick up:
|
||||
**Rules:**
|
||||
|
||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
||||
|
||||
**Typical lifecycle for a new feature:**
|
||||
|
||||
```bash
|
||||
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Never start implementation on an issue without `State/Ready`
|
||||
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
||||
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
|
||||
- When working via the agent loop: label transitions are set automatically
|
||||
by `agent_loop.py` — do **not** set them yourself.
|
||||
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||
```bash
|
||||
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||
```
|
||||
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
||||
- When done and CI is green, close the issue:
|
||||
```bash
|
||||
fgj issue close <NUMBER>
|
||||
```
|
||||
1. Create issue
|
||||
2. Add label loop/plan → agent writes plan as comment
|
||||
3. Review plan, request changes or approve
|
||||
4. Add label loop/code → agent implements + opens PR
|
||||
5. Review PR, merge
|
||||
6. Close issue
|
||||
```
|
||||
|
||||
## Code conventions
|
||||
|
||||
|
||||
+1
-1
@@ -294,7 +294,7 @@ tasks:
|
||||
for attempt in 1 2 3; do
|
||||
run_dagger "$@" && return 0
|
||||
RC=$?
|
||||
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
|
||||
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||
|
||||
@@ -4,6 +4,18 @@ This file contains tasks which got implemented.
|
||||
|
||||
Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks (2026-05-29)
|
||||
|
||||
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
|
||||
all features from PR #307 (issue #299) were already merged into main via separate PRs:
|
||||
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
|
||||
- Configurable back button position for single mail view — merged via #299/#307 features in #300
|
||||
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
|
||||
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
|
||||
- User preferences DB schema (v34–v36: `user_preferences` table) — in main
|
||||
- PR #307 and issue #299 closed.
|
||||
- Issue #315 closed.
|
||||
|
||||
## Tasks (2026-05-26)
|
||||
|
||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||
|
||||
@@ -120,15 +120,76 @@ class _AccountTile extends ConsumerWidget {
|
||||
final health = ref.watch(syncHealthProvider(account.id));
|
||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${account.email}\n$typeLabel'),
|
||||
const SizedBox(height: 4),
|
||||
health.when(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Text('${account.email}\n$typeLabel'),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
status.when(
|
||||
loading: () => const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
data: (_) =>
|
||||
const Icon(Icons.check_circle, color: Colors.green),
|
||||
error: (e, _) => Tooltip(
|
||||
message: e.toString(),
|
||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_AccountAction>(
|
||||
onSelected: (action) => _onAction(context, action),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.syncLog,
|
||||
child: Text('Sync log'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.verifySync,
|
||||
child: Text('Verify sync health'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.forceSync,
|
||||
child: Text('Force full sync'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.edit,
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (_sieveSupported(account))
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersRemote,
|
||||
child: Text('Server email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersLocal,
|
||||
child: Text('Local email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.send,
|
||||
child: Text('Send accounts'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.delete,
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: health.when(
|
||||
data: (h) {
|
||||
if (h == null) return const Text('Sync health: Not verified yet');
|
||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||
@@ -141,7 +202,7 @@ class _AccountTile extends ConsumerWidget {
|
||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
Expanded(
|
||||
child: Text(
|
||||
h.isHealthy
|
||||
? 'Healthy'
|
||||
@@ -155,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
|
||||
loading: () => const Text('Sync health: checking...'),
|
||||
error: (e, _) => Text('Sync health error: $e'),
|
||||
),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
status.when(
|
||||
loading: () => const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
|
||||
error: (e, _) => Tooltip(
|
||||
message: e.toString(),
|
||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_AccountAction>(
|
||||
onSelected: (action) => _onAction(context, action),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.syncLog,
|
||||
child: Text('Sync log'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.verifySync,
|
||||
child: Text('Verify sync health'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.forceSync,
|
||||
child: Text('Force full sync'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.edit,
|
||||
child: Text('Edit'),
|
||||
),
|
||||
if (_sieveSupported(account))
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersRemote,
|
||||
child: Text('Server email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.emailFiltersLocal,
|
||||
child: Text('Local email filters'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.send,
|
||||
child: Text('Send accounts'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: _AccountAction.delete,
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,15 +77,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.forward),
|
||||
tooltip: 'Forward',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_forward(context, header, body));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.archive),
|
||||
tooltip: 'Archive',
|
||||
@@ -121,25 +112,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.report_outlined),
|
||||
tooltip: 'Mark as spam',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.drive_file_move_outline),
|
||||
tooltip: 'Move to folder',
|
||||
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.access_time),
|
||||
tooltip: 'Snooze',
|
||||
onPressed: header == null ? null : () => _snooze(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isFlagged ? Icons.star : Icons.star_border,
|
||||
@@ -154,10 +126,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(
|
||||
value: 'forward',
|
||||
child: Text('Forward'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'move',
|
||||
child: Text('Move to folder'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'snooze',
|
||||
child: Text('Snooze'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'spam',
|
||||
child: Text('Mark as spam'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'mark_unread',
|
||||
child: Text('Mark as unread'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'headers',
|
||||
child: Text('Show Mail Headers'),
|
||||
@@ -172,7 +161,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
],
|
||||
onSelected: (value) async {
|
||||
if (value == 'mark_unread') {
|
||||
if (value == 'forward' && header != null) {
|
||||
unawaited(_forward(context, header, body));
|
||||
} else if (value == 'move' && header != null) {
|
||||
unawaited(_moveTo(context, header));
|
||||
} else if (value == 'snooze' && header != null) {
|
||||
unawaited(_snooze(context, header));
|
||||
} else if (value == 'spam' && header != null) {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
} else if (value == 'mark_unread') {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
|
||||
@@ -111,12 +111,16 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
);
|
||||
|
||||
Future<void> _measureHeight(String _) async {
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
try {
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
}
|
||||
} catch (_) {
|
||||
// WebView not ready yet; height stays at default
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"github-actions": {
|
||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
||||
"enabled": false
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,12 @@ for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
|
||||
fi
|
||||
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
|
||||
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Error: Remote Dagger engine is unavailable AND local Docker daemon is not running."
|
||||
echo "Cannot proceed. Ensure either the remote server at $host:$port is accessible"
|
||||
echo "or that Docker is running locally (check: sudo systemctl start docker)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Remote engine unavailable — CI will use the local Dagger engine."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -252,5 +252,29 @@ void main() {
|
||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'sync health row is positioned below the account name row',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final namePos = tester.getTopLeft(find.text('Alice')).dy;
|
||||
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
|
||||
expect(healthPos, greaterThan(namePos));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,7 +271,8 @@ void main() {
|
||||
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam button is present in app bar', (tester) async {
|
||||
testWidgets('Mark as spam is in popup menu, not a standalone button',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
@@ -282,12 +283,19 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No standalone icon button for mark as spam.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
findsOneWidget,
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
// It appears in the popup menu.
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mark as spam'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Mark as spam shows dialog when no junk folder',
|
||||
@@ -304,11 +312,11 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
);
|
||||
// Open the popup menu first, then tap Mark as spam.
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Mark as spam'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No spam folder found'), findsOneWidget);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
Reference in New Issue
Block a user