5.3 KB165 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {
9 AbsolutePath,
10 PlatformSpecificClientToServerMessages,
11 RepoRelativePath,
12 ServerToClientMessage,
13} from 'isl/src/types';
14import type {Repository} from './Repository';
15import type {RepositoryContext} from './serverTypes';
16
17import {spawn} from 'node:child_process';
18import pathModule from 'node:path';
19import {nullthrows} from 'shared/utils';
20
21/**
22 * Platform-specific server-side API for each target: vscode extension host, electron standalone, browser, ...
23 * See also platform.ts
24 */
25export interface ServerPlatform {
26 platformName: string;
27 /** Override the analytics Session ID. Should be globally unique. */
28 sessionId?: string;
29 handleMessageFromClient(
30 this: ServerPlatform,
31 repo: Repository | undefined,
32 ctx: RepositoryContext,
33 message: PlatformSpecificClientToServerMessages,
34 postMessage: (message: ServerToClientMessage) => void,
35 onDispose: (disapose: () => unknown) => void,
36 ): void | Promise<void>;
37}
38
39export const browserServerPlatform: ServerPlatform = {
40 platformName: 'browser',
41 handleMessageFromClient(
42 this: ServerPlatform,
43 repo: Repository | undefined,
44 ctx: RepositoryContext,
45 message: PlatformSpecificClientToServerMessages,
46 ) {
47 switch (message.type) {
48 case 'platform/openContainingFolder': {
49 const absPath: AbsolutePath = pathModule.join(
50 nullthrows(repo?.info.repoRoot),
51 message.path,
52 );
53 let args: Array<string> = [];
54 // use OS-builtin open command to open parent directory
55 // (which may open different file extensions with different programs)
56 switch (process.platform) {
57 case 'darwin':
58 args = ['/usr/bin/open', pathModule.dirname(absPath)];
59 break;
60 case 'win32':
61 // On windows, we can select the file in the newly opened explorer window by giving the full path
62 args = ['explorer.exe', '/select,', absPath];
63 break;
64 case 'linux':
65 args = ['xdg-open', pathModule.dirname(absPath)];
66 break;
67 }
68 repo?.initialConnectionContext.logger.log('open file', absPath);
69 if (args.length > 0) {
70 spawnInBackground(repo, args);
71 }
72 break;
73 }
74 case 'platform/openFile': {
75 openFile(repo, ctx, message.path);
76 break;
77 }
78 case 'platform/openFiles': {
79 for (const path of message.paths) {
80 openFile(repo, ctx, path);
81 }
82 break;
83 }
84 }
85 },
86};
87
88async function openFile(
89 repo: Repository | undefined,
90 ctx: RepositoryContext | undefined,
91 path: RepoRelativePath,
92) {
93 if (repo == null || ctx == null) {
94 return;
95 }
96 const opener = await repo.getConfig(ctx, 'isl.open-file-cmd');
97 const absPath: AbsolutePath = pathModule.join(repo.info.repoRoot, path);
98 let args: Array<string> = [];
99 if (opener) {
100 // opener should be either a JSON string (wrapped in quotes) or a JSON array of strings,
101 // to include arguments
102 try {
103 const jsonOpenerArgs = JSON.parse(opener);
104 args = Array.isArray(jsonOpenerArgs)
105 ? [...jsonOpenerArgs, absPath]
106 : [jsonOpenerArgs, absPath];
107 } catch {
108 // if it's not JSON, it should be a regular string
109 args = [opener, absPath];
110 }
111 } else {
112 // by default, use OS-builtin open command to open files
113 // (which may open different file extensions with different programs)
114 switch (process.platform) {
115 case 'darwin':
116 args = ['/usr/bin/open', absPath];
117 break;
118 case 'win32':
119 args = ['notepad.exe', absPath];
120 break;
121 case 'linux':
122 args = ['xdg-open', absPath];
123 break;
124 }
125 }
126 repo.initialConnectionContext.logger.log('open file', absPath);
127 if (args.length > 0) {
128 spawnInBackground(repo, args);
129 }
130}
131
132/**
133 * Because the ISL server is likely running in the background and is
134 * no longer attached to a terminal, this is designed for the case
135 * where the user opens the file in a windowed editor (hence
136 * `windowsHide: false`, which is the default for
137 * `child_process.spawn()`, but not for `execa()`):
138 *
139 * - For users using a simple one-window-per-file graphical text
140 * editor, like notepad.exe, this is relatively straightforward.
141 * - For users who prefer a terminal-based editor, like Emacs,
142 * a conduit like EmacsClient would be required.
143 *
144 * Further, killing ISL should not kill the editor, so this follows
145 * the pattern for spawning an independent, long-running process in
146 * Node.js as described here:
147 *
148 * https://nodejs.org/docs/latest-v10.x/api/child_process.html#child_process_options_detached
149 */
150function spawnInBackground(repo: Repository | undefined, args: Array<string>) {
151 // TODO: Report error if spawn() fails?
152 // TODO: support passing the column/line number to programs that support it? e.g. vscode: `code /path/to/file:10:20`
153 const proc = spawn(args[0], args.slice(1), {
154 detached: true,
155 stdio: 'ignore',
156 windowsHide: false,
157 windowsVerbatimArguments: true,
158 });
159 // Silent error. Don't crash the server process.
160 proc.on('error', err => {
161 repo?.initialConnectionContext.logger.log('failed to open', args, err);
162 });
163 proc.unref();
164}
165