3.6 KB128 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 {ContextMenuItem} from 'shared/ContextMenu';
9import type {RepoRelativePath} from './types';
10
11import {Button} from 'isl-components/Button';
12import {Icon} from 'isl-components/Icon';
13import {Subtle} from 'isl-components/Subtle';
14import {atom, useAtomValue} from 'jotai';
15import {useContextMenu} from 'shared/ContextMenu';
16import {minimalDisambiguousPaths} from 'shared/minimalDisambiguousPaths';
17import serverAPI from './ClientToServerAPI';
18import {Column, Row} from './ComponentUtils';
19import {availableCwds} from './CwdSelector';
20import {T, t} from './i18n';
21import {readAtom, writeAtom} from './jotaiUtils';
22import platform from './platform';
23import {showModal} from './useModal';
24import {registerCleanup, registerDisposable} from './utils';
25
26/**
27 * A list of files for this repo that are unsaved in the IDE.
28 * This is always `[]` for unsupported platforms like browser.
29 */
30export const unsavedFiles = atom<Array<{path: RepoRelativePath; uri: string}>>([]);
31registerCleanup(
32 availableCwds,
33 serverAPI.onConnectOrReconnect(() => {
34 serverAPI.postMessage({
35 type: 'platform/subscribeToUnsavedFiles',
36 });
37 }),
38 import.meta.hot,
39);
40registerDisposable(
41 availableCwds,
42 serverAPI.onMessageOfType('platform/unsavedFiles', event =>
43 writeAtom(unsavedFiles, event.unsaved),
44 ),
45 import.meta.hot,
46);
47
48export function UnsavedFilesCount() {
49 const unsaved = useAtomValue(unsavedFiles);
50
51 const menu = useContextMenu(() => {
52 const fullPaths = unsaved.map(({path}) => path);
53 const disambiguated = minimalDisambiguousPaths(fullPaths);
54 const options: Array<ContextMenuItem> = disambiguated.map((name, i) => ({
55 label: t('Open $name', {replace: {$name: name}}),
56 onClick: () => {
57 platform.openFile(fullPaths[i]);
58 },
59 }));
60 options.push({type: 'divider'});
61 options.push({
62 label: t('Save All'),
63 onClick: () => serverAPI.postMessage({type: 'platform/saveAllUnsavedFiles'}),
64 });
65 return options;
66 });
67
68 if (unsaved.length === 0) {
69 return null;
70 }
71 return (
72 <Subtle>
73 <Row>
74 <T count={unsaved.length}>unsavedFileCount</T>
75 <Button icon>
76 <Icon icon="ellipsis" onClick={menu} />
77 </Button>
78 </Row>
79 </Subtle>
80 );
81}
82
83/**
84 * If there are unsaved files, ask the user if they want to save them.
85 * Returns true if the user wants to continue with the operation (after possibly having saved the files),
86 * false if they cancelled.
87 */
88export async function confirmUnsavedFiles(): Promise<boolean> {
89 const unsaved = readAtom(unsavedFiles);
90 if (unsaved.length === 0) {
91 return true;
92 }
93
94 const buttons = [
95 t('Cancel'),
96 t('Continue Without Saving'),
97 {label: t('Save All and Continue'), primary: true},
98 ];
99 const answer = await showModal({
100 type: 'confirm',
101 buttons,
102 title: <T count={unsaved.length}>confirmUnsavedFileCount</T>,
103 message: (
104 <Column alignStart>
105 <Column alignStart>
106 {unsaved.map(({path}) => (
107 <Row key={path}>{path}</Row>
108 ))}
109 </Column>
110 <Row>
111 <T count={unsaved.length}>doYouWantToSaveThem</T>
112 </Row>
113 </Column>
114 ),
115 });
116
117 if (answer === buttons[2]) {
118 serverAPI.postMessage({type: 'platform/saveAllUnsavedFiles'});
119 const message = await serverAPI.nextMessageMatching(
120 'platform/savedAllUnsavedFiles',
121 () => true,
122 );
123 return message.success;
124 }
125
126 return answer === buttons[1];
127}
128