Skip to content

Commit

Permalink
feat: add suspend all instances of an app
Browse files Browse the repository at this point in the history
Resolves #107
  • Loading branch information
Merrit committed Jan 27, 2023
1 parent fd16e68 commit 8f4b8eb
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 36 deletions.
19 changes: 19 additions & 0 deletions lib/apps_list/cubit/apps_list_cubit.dart
Expand Up @@ -150,6 +150,25 @@ class AppsListCubit extends Cubit<AppsListState> {
return successful;
}

/// Toggle suspend/resume for all instances of [window.process.executable].
///
/// For example, if called on mpv and there are multiple windows / instances
/// of the app running, they will all be suspended.
Future<void> toggleAll(Window window) async {
final matchingWindows = state //
.windows
.where((e) =>
(e.process.executable == window.process.executable) &&
// Ensure we only perform the intended action. Eg, if we are
// suspending all but some are already suspended we don't want the
// already suspended instances to resume.
(e.process.status == window.process.status));

for (var match in matchingWindows) {
await toggle(match);
}
}

Future<bool> _resume(Window window) async {
final successful = await _processRepository.resume(window.process.pid);

Expand Down
110 changes: 76 additions & 34 deletions lib/apps_list/widgets/window_tile.dart
Expand Up @@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:helpers/helpers.dart';
import 'package:libadwaita/libadwaita.dart';

import '../../app/app.dart';
Expand Down Expand Up @@ -48,49 +49,90 @@ class _WindowTileState extends State<WindowTile> {

return BlocProvider(
create: (context) => WindowCubit(window),
child: Card(
child: Stack(
children: [
ListTile(
leading: Container(
height: 25,
width: 25,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: (loading) ? null : statusColor,
child: _GestureDetector(
child: Card(
child: Stack(
children: [
ListTile(
leading: Container(
height: 25,
width: 25,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: (loading) ? null : statusColor,
),
child: (loading) ? const CircularProgressIndicator() : null,
),
child: (loading) ? const CircularProgressIndicator() : null,
),
title: Text(window.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('PID: ${window.process.pid}'),
Text(window.process.executable),
],
),
contentPadding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 20,
),
onTap: () async {
log.v('WindowTile clicked: $window');
title: Text(window.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('PID: ${window.process.pid}'),
Text(window.process.executable),
],
),
contentPadding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 20,
),
onTap: () async {
log.v('WindowTile clicked: $window');

setState(() => loading = true);
await context.read<AppsListCubit>().toggle(window);
setState(() => loading = true);
await context.read<AppsListCubit>().toggle(window);

if (!mounted) return;
setState(() => loading = false);
},
trailing: const _DetailsButton(),
),
],
if (!mounted) return;
setState(() => loading = false);
},
trailing: const _DetailsButton(),
),
],
),
),
),
);
}
}

/// Handles right-click on the WindowTile by showing a context menu.
class _GestureDetector extends StatelessWidget {
final Widget child;

const _GestureDetector({
Key? key,
required this.child,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return BlocBuilder<WindowCubit, Window>(
builder: (context, window) {
final availableAction = (window.process.status == ProcessStatus.normal)
? 'Suspend'
: 'Resume';

return GestureDetector(
onSecondaryTapUp: (details) {
showContextMenu(
context: context,
offset: details.globalPosition,
items: [
PopupMenuItem(
onTap: () => appsListCubit.toggleAll(window),
child: Text(
'$availableAction all instances of ${window.process.executable}',
),
),
],
);
},
child: child,
);
},
);
}
}

class _DetailsButton extends StatelessWidget {
const _DetailsButton({Key? key}) : super(key: key);

Expand Down
144 changes: 142 additions & 2 deletions test/apps_list/cubit/apps_list_cubit_test.dart
Expand Up @@ -16,6 +16,9 @@ class MockProcessRepository extends Mock implements ProcessRepository {}

class MockStorageRepository extends Mock implements StorageRepository {}

late AppsListCubit cubit;
AppsListState get state => cubit.state;

const msPaintProcess = Process(
executable: 'mspaint.exe',
pid: 3716,
Expand All @@ -28,8 +31,37 @@ const msPaintWindow = Window(
title: 'Untitled - Paint',
);

late AppsListCubit cubit;
AppsListState get state => cubit.state;
Window get msPaintWindowState => state //
.windows
.singleWhere((element) => element.id == msPaintWindow.id);

const mpvWindow1 = Window(
id: 180355074,
process: Process(
executable: 'mpv',
pid: 1355281,
status: ProcessStatus.normal,
),
title: 'No file - mpv',
);

Window get mpvWindow1State => state //
.windows
.singleWhere((element) => element.id == mpvWindow1.id);

const mpvWindow2 = Window(
id: 197132290,
process: Process(
executable: 'mpv',
pid: 1355477,
status: ProcessStatus.normal,
),
title: 'No file - mpv',
);

Window get mpvWindow2State => state //
.windows
.singleWhere((element) => element.id == mpvWindow2.id);

void main() {
final nativePlatform = MockNativePlatform();
Expand Down Expand Up @@ -246,5 +278,113 @@ void main() {
expect(interactionError.statusAfterInteraction, ProcessStatus.normal);
});
});

group('toggleAll', () {
test('suspends multiple instances correctly', () async {
// Initial setup.
expect(state.windows.isEmpty, true);
when(() => nativePlatform.windows(
showHidden: any(named: 'showHidden'),
)).thenAnswer((_) async => [
msPaintWindow,
mpvWindow1,
mpvWindow2,
]);
await cubit.manualRefresh();
expect(state.windows.length, 3);
expect(msPaintWindowState.process.status, ProcessStatus.normal);
expect(mpvWindow1State.process.status, ProcessStatus.normal);
expect(mpvWindow2State.process.status, ProcessStatus.normal);

// Trigger toggleAll() to suspend mpv instances and verify.
await cubit.toggleAll(mpvWindow1);
when(() => processRepository.getProcessStatus(mpvWindow1.process.pid))
.thenAnswer((_) async => ProcessStatus.suspended);
when(() => processRepository.getProcessStatus(mpvWindow2.process.pid))
.thenAnswer((_) async => ProcessStatus.suspended);
await cubit.manualRefresh();
expect(state.windows.length, 3);
expect(msPaintWindowState.process.status, ProcessStatus.normal);
expect(mpvWindow1State.process.status, ProcessStatus.suspended);
expect(mpvWindow2State.process.status, ProcessStatus.suspended);
});

test('resumes multiple instances correctly', () async {
// Initial setup.
expect(state.windows.isEmpty, true);
when(() => processRepository.getProcessStatus(mpvWindow1.process.pid))
.thenAnswer((_) async => ProcessStatus.suspended);
when(() => processRepository.getProcessStatus(mpvWindow2.process.pid))
.thenAnswer((_) async => ProcessStatus.suspended);
when(() => nativePlatform.windows(
showHidden: any(named: 'showHidden'),
)).thenAnswer((_) async => [
msPaintWindow,
mpvWindow1.copyWith(
process: mpvWindow1.process.copyWith(
status: ProcessStatus.suspended,
),
),
mpvWindow2.copyWith(
process: mpvWindow2.process.copyWith(
status: ProcessStatus.suspended,
),
),
]);
await cubit.manualRefresh();
expect(state.windows.length, 3);
expect(msPaintWindowState.process.status, ProcessStatus.normal);
expect(mpvWindow1State.process.status, ProcessStatus.suspended);
expect(mpvWindow2State.process.status, ProcessStatus.suspended);

// Trigger toggleAll() to resume mpv instances and verify.
await cubit.toggleAll(mpvWindow1);
when(() => processRepository.getProcessStatus(mpvWindow1.process.pid))
.thenAnswer((_) async => ProcessStatus.normal);
when(() => processRepository.getProcessStatus(mpvWindow2.process.pid))
.thenAnswer((_) async => ProcessStatus.normal);
await cubit.manualRefresh();
expect(state.windows.length, 3);
expect(msPaintWindowState.process.status, ProcessStatus.normal);
expect(mpvWindow1State.process.status, ProcessStatus.normal);
expect(mpvWindow2State.process.status, ProcessStatus.normal);
});

test('only suspends if some are already suspended', () async {
// Initial setup.
expect(state.windows.isEmpty, true);
when(() => processRepository.getProcessStatus(mpvWindow2.process.pid))
.thenAnswer((_) async => ProcessStatus.suspended);
when(() => nativePlatform.windows(
showHidden: any(named: 'showHidden'),
)).thenAnswer((_) async => [
msPaintWindow,
mpvWindow1,
mpvWindow2.copyWith(
process: mpvWindow2.process.copyWith(
status: ProcessStatus.suspended,
),
),
]);
await cubit.manualRefresh();
expect(state.windows.length, 3);
expect(msPaintWindowState.process.status, ProcessStatus.normal);
expect(mpvWindow1State.process.status, ProcessStatus.normal);
expect(mpvWindow2State.process.status, ProcessStatus.suspended);

// Trigger toggleAll() to suspend mpv instances and verify,
// the already suspended instance should not have resumed.
await cubit.toggleAll(mpvWindow1);
when(() => processRepository.getProcessStatus(mpvWindow1.process.pid))
.thenAnswer((_) async => ProcessStatus.suspended);
when(() => processRepository.getProcessStatus(mpvWindow2.process.pid))
.thenAnswer((_) async => ProcessStatus.suspended);
await cubit.manualRefresh();
expect(state.windows.length, 3);
expect(msPaintWindowState.process.status, ProcessStatus.normal);
expect(mpvWindow1State.process.status, ProcessStatus.suspended);
expect(mpvWindow2State.process.status, ProcessStatus.suspended);
});
});
});
}

0 comments on commit 8f4b8eb

Please sign in to comment.