[flutter_tools] Launch DevTools with 'v' (#53902)

diff --git a/packages/flutter_tools/lib/src/base/command_help.dart b/packages/flutter_tools/lib/src/base/command_help.dart
index 11c5fbd..b58d5a3 100644
--- a/packages/flutter_tools/lib/src/base/command_help.dart
+++ b/packages/flutter_tools/lib/src/base/command_help.dart
@@ -140,6 +140,12 @@
     'debugDumpRenderTree',
   );
 
+  CommandHelpOption _v;
+  CommandHelpOption get v => _v ??= _makeOption(
+    'v',
+    'Launch DevTools.',
+  );
+
   CommandHelpOption _w;
   CommandHelpOption get w => _w ??= _makeOption(
     'w',
diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart
index 7f013a4..47553f8 100644
--- a/packages/flutter_tools/lib/src/resident_runner.dart
+++ b/packages/flutter_tools/lib/src/resident_runner.dart
@@ -4,6 +4,7 @@
 
 import 'dart:async';
 
+import 'package:devtools_server/devtools_server.dart' as devtools_server;
 import 'package:meta/meta.dart';
 
 import 'application_package.dart';
@@ -650,6 +651,8 @@
 
   final CommandHelp commandHelp;
 
+  io.HttpServer _devtoolsServer;
+
   bool _exited = false;
   Completer<int> _finished = Completer<int>();
   bool hotMode;
@@ -769,12 +772,14 @@
 
   Future<void> exit() async {
     _exited = true;
+    await shutdownDevtools();
     await stopEchoingDeviceLog();
     await preExit();
     await exitApp();
   }
 
   Future<void> detach() async {
+    await shutdownDevtools();
     await stopEchoingDeviceLog();
     await preExit();
     appFinished();
@@ -982,6 +987,31 @@
     }
   }
 
+  Future<void> launchDevTools() async {
+    try {
+      assert(supportsServiceProtocol);
+      _devtoolsServer ??= await devtools_server.serveDevTools(
+        enableStdinCommands: false,
+      );
+      await devtools_server.launchDevTools(
+        <String, dynamic>{
+          'reuseWindows': true,
+        },
+        flutterDevices.first.vmService.httpAddress,
+        'http://${_devtoolsServer.address.host}:${_devtoolsServer.port}',
+        false,  // headless mode,
+        false,  // machine mode
+      );
+    } on Exception catch (e, st) {
+      globals.printTrace('Failed to launch DevTools: $e\n$st');
+    }
+  }
+
+  Future<void> shutdownDevtools() async {
+    await _devtoolsServer?.close();
+    _devtoolsServer = null;
+  }
+
   Future<void> _serviceProtocolDone(dynamic object) async {
     globals.printTrace('Service protocol connection closed.');
   }
@@ -1064,6 +1094,7 @@
       if (supportsCanvasKit){
         commandHelp.k.print();
       }
+      commandHelp.v.print();
       // `P` should precede `a`
       commandHelp.P.print();
       commandHelp.a.print();
@@ -1299,6 +1330,12 @@
           return true;
         }
         return false;
+      case 'v':
+        if (residentRunner.supportsServiceProtocol) {
+          await residentRunner.launchDevTools();
+          return true;
+        }
+        return false;
       case 'w':
       case 'W':
         if (residentRunner.supportsServiceProtocol) {
diff --git a/packages/flutter_tools/test/general.shard/base/command_help_test.dart b/packages/flutter_tools/test/general.shard/base/command_help_test.dart
index daa9b64..6b97735 100644
--- a/packages/flutter_tools/test/general.shard/base/command_help_test.dart
+++ b/packages/flutter_tools/test/general.shard/base/command_help_test.dart
@@ -66,6 +66,7 @@
   expect(commandHelp.r.toString().length, lessThanOrEqualTo(expectedWidth));
   expect(commandHelp.s.toString().length, lessThanOrEqualTo(expectedWidth));
   expect(commandHelp.t.toString().length, lessThanOrEqualTo(expectedWidth));
+  expect(commandHelp.v.toString().length, lessThanOrEqualTo(expectedWidth));
   expect(commandHelp.w.toString().length, lessThanOrEqualTo(expectedWidth));
   expect(commandHelp.z.toString().length, lessThanOrEqualTo(expectedWidth));
 }
@@ -95,6 +96,7 @@
         expect(commandHelp.r.toString(), startsWith('\x1B[1mr\x1B[22m'));
         expect(commandHelp.s.toString(), startsWith('\x1B[1ms\x1B[22m'));
         expect(commandHelp.t.toString(), startsWith('\x1B[1mt\x1B[22m'));
+        expect(commandHelp.v.toString(), startsWith('\x1B[1mv\x1B[22m'));
         expect(commandHelp.w.toString(), startsWith('\x1B[1mw\x1B[22m'));
         expect(commandHelp.z.toString(), startsWith('\x1B[1mz\x1B[22m'));
       });
@@ -170,6 +172,7 @@
         expect(commandHelp.r.toString(), equals('\x1B[1mr\x1B[22m Hot reload. $fire$fire$fire'));
         expect(commandHelp.s.toString(), equals('\x1B[1ms\x1B[22m Save a screenshot to flutter.png.'));
         expect(commandHelp.t.toString(), equals('\x1B[1mt\x1B[22m Dump rendering tree to the console.                          \x1B[1;30m(debugDumpRenderTree)\x1B[39m'));
+        expect(commandHelp.v.toString(), equals('\x1B[1mv\x1B[22m Launch DevTools.'));
         expect(commandHelp.w.toString(), equals('\x1B[1mw\x1B[22m Dump widget hierarchy to the console.                               \x1B[1;30m(debugDumpApp)\x1B[39m'));
         expect(commandHelp.z.toString(), equals('\x1B[1mz\x1B[22m Toggle elevation checker.'));
       });
@@ -195,6 +198,7 @@
         expect(commandHelp.r.toString(), equals('r Hot reload. $fire$fire$fire'));
         expect(commandHelp.s.toString(), equals('s Save a screenshot to flutter.png.'));
         expect(commandHelp.t.toString(), equals('t Dump rendering tree to the console.                          (debugDumpRenderTree)'));
+        expect(commandHelp.v.toString(), equals('v Launch DevTools.'));
         expect(commandHelp.w.toString(), equals('w Dump widget hierarchy to the console.                               (debugDumpApp)'));
         expect(commandHelp.z.toString(), equals('z Toggle elevation checker.'));
       });
diff --git a/packages/flutter_tools/test/general.shard/resident_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_runner_test.dart
index bbf44b2..991f772 100644
--- a/packages/flutter_tools/test/general.shard/resident_runner_test.dart
+++ b/packages/flutter_tools/test/general.shard/resident_runner_test.dart
@@ -406,6 +406,7 @@
           commandHelp.p,
           commandHelp.o,
           commandHelp.z,
+          commandHelp.v,
           commandHelp.P,
           commandHelp.a,
           'An Observatory debugger and profiler on null is available at: null',
diff --git a/packages/flutter_tools/test/general.shard/terminal_handler_test.dart b/packages/flutter_tools/test/general.shard/terminal_handler_test.dart
index 7364771..4c289b9 100644
--- a/packages/flutter_tools/test/general.shard/terminal_handler_test.dart
+++ b/packages/flutter_tools/test/general.shard/terminal_handler_test.dart
@@ -364,6 +364,13 @@
       verifyNever(mockResidentRunner.debugDumpSemanticsTreeInInverseHitTestOrder());
     });
 
+    testUsingContext('v - launchDevTools', () async {
+      when(mockResidentRunner.supportsServiceProtocol).thenReturn(true);
+      await terminalHandler.processTerminalInput('v');
+
+      verify(mockResidentRunner.launchDevTools()).called(1);
+    });
+
     testUsingContext('w,W - debugDumpApp with service protocol', () async {
       await terminalHandler.processTerminalInput('w');
       await terminalHandler.processTerminalInput('W');