Skip to content

Commit

Permalink
Warn when closing window with unsaved changes (#1230)
Browse files Browse the repository at this point in the history
* add MessageBox component

* use element instead of string as message, since lacking rich text

* refactor: replace Buffers.ofBufferOpt with Option.map

* add actions

* trigger modal from canQuit callback

* model

* behaviour

* view
  • Loading branch information
glennsl authored Jan 21, 2020
1 parent 9f5334f commit c5437d8
Show file tree
Hide file tree
Showing 10 changed files with 267 additions and 48 deletions.
71 changes: 71 additions & 0 deletions src/Components/MessageBox.re
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
open Oni_Core;

open Revery.UI;
open Revery.UI.Components;

type action('msg) = {
label: string,
msg: 'msg,
};

module Styles = {
open Style;

let container = (~theme: Theme.t) => [backgroundColor(theme.background)];

let message = [
padding(20),
paddingBottom(30) // I don't know why this is needed, but it is
];

let actions = [flexDirection(`Row)];

let buttonOuter = (~isHovered, ~theme: Theme.t) => [
isHovered
? backgroundColor(theme.menuSelectionBackground)
: backgroundColor(theme.editorBackground),
flexGrow(1),
];

let buttonInner = [padding(10)];

let buttonText = (~isHovered, ~theme: Theme.t, ~font: UiFont.t) => [
fontFamily(font.fontFile),
color(theme.foreground),
isHovered
? backgroundColor(theme.menuSelectionBackground)
: backgroundColor(theme.editorBackground),
fontSize(14),
alignSelf(`Center),
];
};

let%component button = (~text, ~onClick, ~theme, ~font, ()) => {
let%hook (isHovered, setHovered) = Hooks.state(false);

<Clickable onClick style={Styles.buttonOuter(~theme, ~isHovered)}>
<View
onMouseOver={_ => setHovered(_ => true)}
onMouseOut={_ => setHovered(_ => false)}
style=Styles.buttonInner>
<Text style={Styles.buttonText(~isHovered, ~theme, ~font)} text />
</View>
</Clickable>;
};

let make = (~children as message, ~theme, ~font, ~actions, ~onAction, ()) =>
<View style={Styles.container(~theme)}>
<View style=Styles.message> message </View>
<View style=Styles.actions>
{actions
|> List.map(action =>
<button
text={action.label}
onClick={() => onAction(action.msg)}
theme
font
/>
)
|> React.listToElement}
</View>
</View>;
4 changes: 4 additions & 0 deletions src/Model/Actions.re
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ type t =
| Sneak(Sneak.action)
| PaneTabClicked(Pane.paneType)
| VimDirectoryChanged(string)
| WindowCloseBlocked
| WindowCloseDiscardConfirmed
| WindowCloseSaveAllConfirmed
| WindowCloseCanceled
// "Internal" effect action, see TitleStoreConnector
| SetTitle(string)
| Noop
Expand Down
15 changes: 4 additions & 11 deletions src/Model/Buffers.re
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,17 @@ let isModifiedByPath = (buffers: t, filePath: string) => {
);
};

let ofBufferOpt = (f, buffer) => {
switch (buffer) {
| None => None
| Some(b) => Some(f(b))
};
};

let applyBufferUpdate = bufferUpdate =>
ofBufferOpt(buffer => Buffer.update(buffer, bufferUpdate));
Option.map(buffer => Buffer.update(buffer, bufferUpdate));

let setIndentation = indent =>
ofBufferOpt(buffer => Buffer.setIndentation(indent, buffer));
Option.map(buffer => Buffer.setIndentation(indent, buffer));

let disableSyntaxHighlighting =
ofBufferOpt(buffer => Buffer.disableSyntaxHighlighting(buffer));
Option.map(buffer => Buffer.disableSyntaxHighlighting(buffer));

let setModified = modified =>
ofBufferOpt(buffer => Buffer.setModified(modified, buffer));
Option.map(buffer => Buffer.setModified(modified, buffer));

let reduce = (state: t, action: Actions.t) => {
switch (action) {
Expand Down
2 changes: 2 additions & 0 deletions src/Model/Modal.re
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
type t =
| UnsavedBuffersWarning;
2 changes: 2 additions & 0 deletions src/Model/State.re
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type t = {
pane: Pane.t,
searchPane: Feature_Search.model,
focus: Focus.stack,
modal: option(Modal.t),
};

let create: unit => t =
Expand Down Expand Up @@ -102,4 +103,5 @@ let create: unit => t =
pane: Pane.initial,
searchPane: Feature_Search.initial,
focus: Focus.initial,
modal: None,
};
52 changes: 40 additions & 12 deletions src/Store/LifecycleStoreConnector.re
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,77 @@
* - Handling quit cleanup
*/

module Core = Oni_Core;
module Model = Oni_Model;
open Oni_Model;

let start = quit => {
let (stream, dispatch) = Isolinear.Stream.create();

let quitAllEffect = (state: Model.State.t, force) => {
let saveAllAndQuitEffect =
Isolinear.Effect.create(~name="lifecycle.saveAllAndQuit", () => {
Vim.input("<ESC>") |> (ignore: list(Vim.Cursor.t) => unit);
Vim.input("<ESC>") |> (ignore: list(Vim.Cursor.t) => unit);
Vim.input(":") |> (ignore: list(Vim.Cursor.t) => unit);
Vim.input("x") |> (ignore: list(Vim.Cursor.t) => unit);
Vim.input("a") |> (ignore: list(Vim.Cursor.t) => unit);
Vim.input("<CR>") |> (ignore: list(Vim.Cursor.t) => unit);
});

let quitAllEffect = (state: State.t, force) => {
let handlers = state.lifecycle.onQuitFunctions;

let anyModified = Model.Buffers.anyModified(state.buffers);
let anyModified = Buffers.anyModified(state.buffers);
let canClose = force || !anyModified;

Isolinear.Effect.create(~name="lifecycle.quit", () =>
Isolinear.Effect.create(~name="lifecycle.quitAll", () =>
if (canClose) {
List.iter(h => h(), handlers);
quit(0);
}
);
};

let quitBufferEffect = (state: Model.State.t, buffer: Vim.Buffer.t, force) => {
let quitBufferEffect = (state: State.t, buffer: Vim.Buffer.t, force) => {
Isolinear.Effect.create(~name="lifecycle.quitBuffer", () => {
let editorGroup = Model.Selectors.getActiveEditorGroup(state);
switch (Model.Selectors.getActiveEditor(editorGroup)) {
let editorGroup = Selectors.getActiveEditorGroup(state);
switch (Selectors.getActiveEditor(editorGroup)) {
| None => ()
| Some(editor) =>
let bufferMeta = Vim.BufferMetadata.ofBuffer(buffer);
if (editor.bufferId == bufferMeta.id) {
if (force || !bufferMeta.modified) {
dispatch(Model.Actions.ViewCloseEditor(editor.editorId));
dispatch(Actions.ViewCloseEditor(editor.editorId));
};
};
};
});
};

let updater = (state: Model.State.t, action) => {
let updater = (state: State.t, action) => {
switch (action) {
| Model.Actions.QuitBuffer(buffer, force) => (
| Actions.QuitBuffer(buffer, force) => (
state,
quitBufferEffect(state, buffer, force),
)
| Model.Actions.Quit(force) => (state, quitAllEffect(state, force))

| Actions.Quit(force) => (state, quitAllEffect(state, force))

| WindowCloseBlocked => (
{...state, modal: Some(UnsavedBuffersWarning)},
Isolinear.Effect.none,
)

| WindowCloseDiscardConfirmed => (
{...state, modal: None},
quitAllEffect(state, true),
)

| WindowCloseSaveAllConfirmed => (
{...state, modal: None},
saveAllAndQuitEffect,
)

| WindowCloseCanceled => ({...state, modal: None}, Isolinear.Effect.none)

| _ => (state, Isolinear.Effect.none)
};
};
Expand Down
13 changes: 13 additions & 0 deletions src/Store/StoreThread.re
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ let start =

latestRunEffects := Some(runEffects);

Option.iter(
window =>
Revery.Window.setCanQuitCallback(window, () =>
if (Model.Buffers.anyModified(latestState^.buffers)) {
dispatch(Model.Actions.WindowCloseBlocked);
false;
} else {
true;
}
),
window,
);

let editorEventStream =
Isolinear.Stream.map(storeStream, ((state, action)) =>
switch (action) {
Expand Down
98 changes: 98 additions & 0 deletions src/UI/Modals.re
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
open Revery;
open Revery.UI;

open Oni_Core;
open Utility;
open Oni_Model;
open Oni_Components;
open Actions;

module Styles = {
open Style;

let overlay = [
backgroundColor(Color.hex("#0004")),
position(`Absolute),
top(0),
left(0),
right(0),
bottom(0),
alignItems(`Center),
justifyContent(`Center),
overflow(`Hidden),
flexDirection(`Column),
pointerEvents(`Allow),
];

let text = (~theme: Theme.t, ~font: UiFont.t) => [
fontFamily(font.fontFile),
color(theme.editorForeground),
backgroundColor(theme.editorBackground),
fontSize(14),
];

let files = [padding(10)];

let file = (~theme: Theme.t, ~font: UiFont.t) => [
fontFamily(font.fontFile),
color(theme.foreground),
backgroundColor(theme.editorBackground),
fontSize(14),
];
};

let unsavedBufferWarning =
(~workingDirectory, ~buffers, ~theme, ~uiFont as font, ()) => {
let modifiedFiles =
buffers
|> IntMap.to_seq
|> Seq.map(snd)
|> Seq.filter(Buffer.isModified)
|> Seq.map(Buffer.getFilePath)
|> List.of_seq
|> OptionEx.values
|> List.map(
Path.toRelative(~base=Option.value(workingDirectory, ~default="")),
);

<MessageBox
actions=MessageBox.[
{label: "Discard Changes", msg: WindowCloseDiscardConfirmed},
{label: "Cancel", msg: WindowCloseCanceled},
{label: "Save All", msg: WindowCloseSaveAllConfirmed},
]
onAction={GlobalContext.current().dispatch}
theme
font>
<Text
style={Styles.text(~theme, ~font)}
text="You have unsaved changes in the following files:"
/>
<View style=Styles.files>
{modifiedFiles
|> List.map(text => <Text style={Styles.file(~theme, ~font)} text />)
|> React.listToElement}
</View>
<Text
style={Styles.text(~theme, ~font)}
text="Would you like to to save them before closing?"
/>
</MessageBox>;
};

let make = (~state: State.t, ()) => {
let State.{theme, uiFont, buffers, workspace, _} = state;
let workingDirectory =
Option.map(ws => ws.Workspace.workingDirectory, workspace);

switch (state.modal) {
| None => React.empty
| Some(modal) =>
<View style=Styles.overlay>
{switch (modal) {
| UnsavedBuffersWarning =>
<unsavedBufferWarning workingDirectory buffers theme uiFont />
}}
</View>
};
};
Loading

0 comments on commit c5437d8

Please sign in to comment.