Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: save and restore workspaces, add menu bar #79

Merged
merged 12 commits into from
Jun 4, 2024
1 change: 1 addition & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"@babel/generator": "^7.24.4",
"@babel/types": "^7.24.0",
"idb": "^8.0.0",
"monaco-editor": "^0.47.0",
"sandybox": "^1.1.2",
"solid-js": "^1.8.16",
Expand Down
71 changes: 67 additions & 4 deletions apps/playground/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import * as monaco from 'monaco-editor';
import { For, Show, createMemo, createSignal, onCleanup } from 'solid-js';
import {
For,
Show,
batch,
createMemo,
createSignal,
onCleanup,
} from 'solid-js';
import { createStore } from 'solid-js/store';
import Breadcrumbs from './components/Breadcrumbs';
import Menu from './components/Menu';
import MonacoEditor from './components/MonacoEditor';
import ProgressBar from './components/ProgressBar';
import Sidebar from './components/Sidebar';
import Tab from './components/Tab';
import { DeobfuscateContextProvider } from './context/DeobfuscateContext';
import { settings } from './hooks/useSettings';
import { useWorkspaces, type Workspace } from './indexeddb';
import { debounce } from './utils/debounce';
import { downloadFile } from './utils/download';
import type { DeobfuscateResult } from './webcrack.worker';

export const [settings, setSettings] = createStore({
export const [config, setConfig] = createStore({
deobfuscate: true,
unminify: true,
unpack: true,
Expand All @@ -17,6 +30,7 @@ export const [settings, setSettings] = createStore({
});

function App() {
const { saveModels, setWorkspaceId } = useWorkspaces();
const [untitledCounter, setUntitledCounter] = createSignal(1);
const [models, setModels] = createSignal<monaco.editor.ITextModel[]>([
monaco.editor.createModel(
Expand All @@ -39,6 +53,41 @@ function App() {
const filePaths = createMemo(() =>
fileModels().map((model) => model.uri.path),
);
const hasNonEmptyModels = () => models().some((m) => m.getValueLength() > 0);

window.onbeforeunload = () => {
if (settings.confirmOnLeave && hasNonEmptyModels()) {
saveModels(models()).catch(console.error);
return true;
}
return undefined;
};

const saveModelsDebounced = debounce(() => {
settings.workspaceHistory && saveModels(models()).catch(console.error);
}, 1000);

async function restoreWorkspace(workspace: Workspace) {
await saveModels(models());
setWorkspaceId(workspace.id);

batch(() => {
models().forEach((model) => model.dispose());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this clear any currently open tabs? If so, I wonder if that might be unexpected for users sometimes?

Realistically (at least with the current 5sec countdown on the restore dialog) it's probably rare (but not impossible) that you would get in a state of having open work that gets lost via this.

I wonder if making it so it only closes if it's the default/empty 'new tab' might be safer?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this clear any currently open tabs

Yes, they can't be kept because each model needs to have an unique URI (path)
A possibility would be saving them before or showing a confirm dialog


setModels(
workspace.models.map((model) =>
monaco.editor.createModel(
model.value,
model.language,
monaco.Uri.parse(model.uri),
),
),
);

setTabs(untitledModels());
setActiveTab(untitledModels()[0]);
});
}

onCleanup(() => {
models().forEach((model) => model.dispose());
Expand Down Expand Up @@ -138,11 +187,24 @@ function App() {
return (
<DeobfuscateContextProvider
code={activeTab()?.getValue()}
options={{ ...settings }}
options={{ ...config }}
onResult={onDeobfuscateResult}
onError={onDeobfuscateError}
>
<div class="h-screen flex">
<ProgressBar />
<Menu
onFileOpen={(content) => {
openUntitledTab().setValue(content);
}}
onSave={() => {
if (activeTab()) downloadFile(activeTab()!);
}}
onRestore={(workspace) => {
restoreWorkspace(workspace).catch(console.error);
}}
/>

<div class="flex" style="height: calc(100vh - 44px)">
<Sidebar paths={filePaths()} onFileClick={openFile} />

<main class="flex-1 overflow-auto">
Expand Down Expand Up @@ -182,6 +244,7 @@ function App() {
models={models()}
currentModel={activeTab()}
onModelChange={openTab}
onValueChange={saveModelsDebounced}
/>
</main>
</div>
Expand Down
130 changes: 130 additions & 0 deletions apps/playground/src/components/Menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { For } from 'solid-js';
import { setSettings, settings, type Settings } from '../hooks/useSettings';
import { useWorkspaces, type Workspace } from '../indexeddb';

interface Props {
onFileOpen?: (content: string) => void;
onSave?: () => void;
onRestore?: (workspace: Workspace) => void;
}

export default function Menu(props: Props) {
const { workspaces } = useWorkspaces();

function openFile() {
const input = document.createElement('input');
input.type = 'file';
input.onchange = async (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const content = await file.text();
props.onFileOpen?.(content);
};
input.click();
}

return (
<ul class="menu menu-sm menu-horizontal bg-base-200 w-full">
<li>
<details>
<summary>File</summary>
<ul class="min-w-52 z-10 !px-0">
<li>
<a onClick={openFile}>Open File…</a>
</li>
<li>
<div class="dropdown dropdown-right dropdown-hover transform-none">
<div tabindex="0" role="button">
Open Recent
</div>
<ul
tabindex="0"
class="dropdown-content z-10 menu ml-0 p-2 shadow bg-base-100 rounded-box"
>
<For each={workspaces()} fallback={<li>No recent files</li>}>
{(workspace) => (
<li>
<a
onClick={() => props.onRestore?.(workspace)}
class="truncate"
>
{new Date(workspace.timestamp).toLocaleString()} -
<code>{workspace.models[0].value.slice(0, 20)}…</code>
</a>
</li>
)}
</For>
</ul>
</div>
</li>
<li>
<a onClick={props.onSave}>Save</a>
</li>
</ul>
</details>
</li>
<li>
<details>
<summary>Settings</summary>
<ul class="min-w-52 z-10">
<li>
<label class="h-10 flex items-center">
Theme
<select
class="select select-sm ml-auto"
value={settings.theme}
onChange={(e) =>
setSettings(
'theme',
e.currentTarget.value as Settings['theme'],
)
}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
</select>
</label>
</li>
<li>
<label class="h-10 flex items-center">
Confirm on Leave
<input
type="checkbox"
class="checkbox checkbox-sm ml-auto"
checked={settings.confirmOnLeave}
onChange={(e) =>
setSettings('confirmOnLeave', e.currentTarget.checked)
}
/>
</label>
</li>
<li>
<label class="h-10 flex items-center">
Workspace History
<input
type="checkbox"
class="checkbox checkbox-sm ml-auto"
checked={settings.workspaceHistory}
onChange={(e) =>
setSettings('workspaceHistory', e.currentTarget.checked)
}
/>
</label>
</li>
</ul>
</details>
</li>
<li>
<a href="https://github.com/j4k0xb/webcrack" target="_blank">
GitHub
</a>
</li>
<li>
<a href="/docs" target="_blank">
Documentation
</a>
</li>
</ul>
);
}
46 changes: 17 additions & 29 deletions apps/playground/src/components/MonacoEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import * as monaco from 'monaco-editor';
import { createEffect, onCleanup, onMount } from 'solid-js';
import { useDeobfuscateContext } from '../context/DeobfuscateContext';
import { useTheme } from '../hooks/useTheme';
import { theme } from '../hooks/useTheme';
import { registerEvalSelection } from '../monaco/eval-selection';
import { PlaceholderContentWidget } from '../monaco/placeholder-widget';
import { downloadFile } from '../utils/download';

interface Props {
models: monaco.editor.ITextModel[];
currentModel?: monaco.editor.ITextModel;
onModelChange?: (model: monaco.editor.ITextModel) => void;
onValueChange?: (value: string) => void;
onSave?: (value: string) => void;
}

monaco.editor.defineTheme('dark', {
Expand All @@ -20,7 +23,6 @@ monaco.editor.defineTheme('dark', {

export default function MonacoEditor(props: Props) {
const { deobfuscate } = useDeobfuscateContext();
const [theme] = useTheme();
const viewStates = new WeakMap<
monaco.editor.ITextModel,
monaco.editor.ICodeEditorViewState
Expand Down Expand Up @@ -57,6 +59,11 @@ export default function MonacoEditor(props: Props) {
editor.focus();
}

editor.onDidChangeModelContent(() => {
const model = editor.getModel();
if (model) props.onValueChange?.(model.getValue());
});
j4k0xb marked this conversation as resolved.
Show resolved Hide resolved

// Go to definition
const editorOpener = monaco.editor.registerEditorOpener({
openCodeEditor(_source, resource, selectionOrPosition) {
Expand Down Expand Up @@ -100,29 +107,13 @@ export default function MonacoEditor(props: Props) {

const saveAction = editor.addAction({
id: 'editor.action.save',
label: 'Save',
label: 'File: Save',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run(editor) {
const code = editor.getValue();
if (code === '') return;

const blob = new Blob([code], {
type: 'application/javascript;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');

link.setAttribute('href', url);
link.setAttribute(
'download',
`deobfuscated-${new Date().toISOString()}.js`,
);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

URL.revokeObjectURL(url);
run() {
const model = editor.getModel();
if (model) {
downloadFile(model);
}
},
});

Expand All @@ -138,16 +129,13 @@ export default function MonacoEditor(props: Props) {
editorOpener.dispose();
placeholder.dispose();
deobfuscateAction.dispose();
saveAction.dispose();
evalAction.dispose();
saveAction.dispose();
});
});

return (
<div
ref={container}
class="editor"
style="height: calc(100vh - 64px)"
></div>
<div ref={container} class="editor" style="height: calc(100vh - 108px)" />
);
}
29 changes: 29 additions & 0 deletions apps/playground/src/components/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Show, createEffect, createSignal } from 'solid-js';
import { useDeobfuscateContext } from '../context/DeobfuscateContext';

export default function ProgressBar() {
const { deobfuscating, progress } = useDeobfuscateContext();
const [progressShown, setProgressShown] = createSignal(false);

createEffect(() => {
if (deobfuscating()) setProgressShown(true);
else if (progress() === 100) setTimeout(() => setProgressShown(false), 500);
else setProgressShown(false);
});

return (
<Show when={progressShown()}>
<style>
{`
.progress::-webkit-progress-value {
transition: width 300ms ease;
}
`}
</style>
<progress
class="progress progress-info absolute top-0 h-0.5 w-full z-10 pointer-events-none bg-transparent"
value={progress() / 100}
/>
</Show>
);
}
Loading
Loading