Skip to content

Commit 4a7ba29

Browse files
committed
feat: electron app
1 parent 5326f8d commit 4a7ba29

12 files changed

Lines changed: 5592 additions & 95 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Build Electron App
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*' # trigger on version tags: git tag v0.1.0 && git push --tags
7+
workflow_dispatch: # or trigger manually from GitHub Actions tab
8+
9+
jobs:
10+
build:
11+
strategy:
12+
matrix:
13+
include:
14+
- os: windows-latest
15+
script: electron:build:win
16+
artifact: dist/*.exe
17+
- os: macos-latest
18+
script: electron:build:mac
19+
artifact: dist/*.dmg
20+
- os: ubuntu-latest
21+
script: electron:build:linux
22+
artifact: dist/*.AppImage
23+
24+
runs-on: ${{ matrix.os }}
25+
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- uses: actions/setup-node@v4
30+
with:
31+
node-version: '22'
32+
cache: 'npm'
33+
34+
- name: Install dependencies
35+
run: npm ci
36+
37+
- name: Build
38+
run: npm run ${{ matrix.script }}
39+
env:
40+
# electron-builder uses this to skip code signing in CI
41+
CSC_IDENTITY_AUTO_DISCOVERY: false
42+
# Mac notarization — set these in GitHub repo secrets if you need signed builds
43+
# APPLE_ID: ${{ secrets.APPLE_ID }}
44+
# APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
45+
46+
- name: Upload artifact
47+
uses: actions/upload-artifact@v4
48+
with:
49+
name: LoCode-${{ matrix.os }}
50+
path: ${{ matrix.artifact }}
51+
if-no-files-found: error

app/components/FileExplorer.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
<script setup lang="ts">
141141
import type { SkeletonNode } from '~/composables/useLocodeConfig'
142142
import { DEFAULT_SKELETON } from '~/composables/useLocodeConfig'
143+
const { apiFetch } = useApi()
143144
144145
const props = defineProps<{
145146
openFiles: string[];
@@ -209,7 +210,7 @@ provide("hideTooltip", () => {
209210
210211
async function loadTree(path: string, dirsOnly = false): Promise<any[]> {
211212
folder.value = path;
212-
const res = await fetch("/api/list?path=" + path);
213+
const res = await apiFetch("/list?path=" + path);
213214
let items = await res.json();
214215
if (dirsOnly) items = items.filter((n: any) => n.type === "dir");
215216
return items;

app/components/SettingsModal.vue

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<template>
2+
<Teleport to="body">
3+
<Transition name="modal">
4+
<div v-if="show" class="dialog-backdrop" @click="$emit('close')">
5+
<div class="dialog" @click.stop>
6+
<button class="dialog-close" @click="$emit('close')">&times;</button>
7+
<p class="dialog-title">Settings</p>
8+
9+
<div class="field">
10+
<label class="field-label">Remote Backend URL</label>
11+
<input
12+
v-model="urlInput"
13+
class="field-input"
14+
type="url"
15+
placeholder="http://localhost:8080"
16+
spellcheck="false"
17+
/>
18+
<p class="field-hint">
19+
Leave empty for local mode. In SSH mode, set up a tunnel
20+
(<code>ssh -L 8080:localhost:8080 user@host</code>) then enter
21+
<code>http://localhost:8080</code>. The terminal will spawn on that machine.
22+
</p>
23+
</div>
24+
25+
<div class="dialog-actions">
26+
<button class="dialog-btn save" @click="save">Save</button>
27+
<button class="dialog-btn cancel" @click="$emit('close')">Cancel</button>
28+
</div>
29+
</div>
30+
</div>
31+
</Transition>
32+
</Teleport>
33+
</template>
34+
35+
<script setup lang="ts">
36+
const props = defineProps<{ show: boolean }>();
37+
const emit = defineEmits<{ (e: "close"): void; (e: "saved"): void }>();
38+
39+
const urlInput = ref("");
40+
41+
watch(() => props.show, (visible) => {
42+
if (visible) {
43+
urlInput.value = import.meta.client
44+
? (localStorage.getItem("locode:backendUrl") || "")
45+
: "";
46+
}
47+
});
48+
49+
function save() {
50+
const trimmed = urlInput.value.trim().replace(/\/$/, "");
51+
if (import.meta.client) {
52+
if (trimmed) {
53+
localStorage.setItem("locode:backendUrl", trimmed);
54+
} else {
55+
localStorage.removeItem("locode:backendUrl");
56+
}
57+
}
58+
emit("saved");
59+
emit("close");
60+
}
61+
</script>
62+
63+
<style lang="css" scoped>
64+
.dialog-backdrop {
65+
position: fixed;
66+
inset: 0;
67+
z-index: 100;
68+
background: rgba(0, 0, 0, 0.45);
69+
backdrop-filter: blur(6px);
70+
-webkit-backdrop-filter: blur(6px);
71+
display: flex;
72+
align-items: center;
73+
justify-content: center;
74+
}
75+
76+
.dialog {
77+
position: relative;
78+
background-color: rgba(30, 30, 30, 0.88);
79+
backdrop-filter: blur(24px);
80+
-webkit-backdrop-filter: blur(24px);
81+
border: 1.5px solid rgba(255, 255, 255, 0.2);
82+
border-radius: 12px;
83+
padding: 24px;
84+
max-width: 440px;
85+
width: 90%;
86+
box-shadow:
87+
0 4px 16px rgba(0, 0, 0, 0.35),
88+
0 20px 60px rgba(0, 0, 0, 0.55),
89+
inset 0 1px 0 rgba(255, 255, 255, 0.07);
90+
}
91+
92+
.dialog-close {
93+
position: absolute;
94+
top: 10px;
95+
right: 12px;
96+
width: 24px;
97+
height: 24px;
98+
display: flex;
99+
align-items: center;
100+
justify-content: center;
101+
font-size: 1rem;
102+
color: rgba(255, 255, 255, 0.5);
103+
background: transparent;
104+
border: none;
105+
border-radius: 4px;
106+
cursor: pointer;
107+
transition: color 0.15s ease, background 0.15s ease,
108+
transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
109+
}
110+
.dialog-close:hover {
111+
color: rgba(255, 255, 255, 0.9);
112+
background: rgba(220, 100, 100, 0.4);
113+
transform: scale(1.2);
114+
}
115+
116+
.dialog-title {
117+
color: rgba(255, 255, 255, 0.9);
118+
font-size: 1rem;
119+
font-weight: 700;
120+
margin-bottom: 18px;
121+
}
122+
123+
.field {
124+
display: flex;
125+
flex-direction: column;
126+
gap: 6px;
127+
margin-bottom: 20px;
128+
}
129+
130+
.field-label {
131+
font-size: 0.8rem;
132+
font-weight: 600;
133+
color: rgba(255, 255, 255, 0.6);
134+
text-transform: uppercase;
135+
letter-spacing: 0.04em;
136+
}
137+
138+
.field-input {
139+
background: rgba(255, 255, 255, 0.07);
140+
border: 1.5px solid rgba(255, 255, 255, 0.15);
141+
border-radius: 6px;
142+
padding: 8px 10px;
143+
font-size: 0.85rem;
144+
font-family: ui-monospace, monospace;
145+
color: rgba(255, 255, 255, 0.9);
146+
outline: none;
147+
transition: border-color 0.15s ease;
148+
}
149+
.field-input:focus {
150+
border-color: rgba(100, 180, 255, 0.5);
151+
}
152+
.field-input::placeholder {
153+
color: rgba(255, 255, 255, 0.25);
154+
}
155+
156+
.field-hint {
157+
font-size: 0.75rem;
158+
color: rgba(255, 255, 255, 0.4);
159+
line-height: 1.5;
160+
}
161+
.field-hint code {
162+
font-family: ui-monospace, monospace;
163+
background: rgba(255, 255, 255, 0.08);
164+
border-radius: 3px;
165+
padding: 1px 4px;
166+
font-size: 0.72rem;
167+
}
168+
169+
.dialog-actions {
170+
display: flex;
171+
gap: 8px;
172+
}
173+
174+
.dialog-btn {
175+
flex: 1;
176+
padding: 8px 12px;
177+
font-size: 0.85rem;
178+
font-weight: 700;
179+
border-radius: 5px;
180+
cursor: pointer;
181+
color: rgba(255, 255, 255, 0.9);
182+
border: 1px solid rgba(255, 255, 255, 0.15);
183+
transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
184+
background 0.15s ease, box-shadow 0.15s ease;
185+
}
186+
.dialog-btn:active { transform: scale(0.93); transition: transform 0.08s ease; }
187+
188+
.dialog-btn.save {
189+
background: rgba(100, 180, 255, 0.25);
190+
border-color: rgba(100, 180, 255, 0.4);
191+
}
192+
.dialog-btn.save:hover {
193+
background: rgba(100, 180, 255, 0.4);
194+
box-shadow: 0 0 14px rgba(100, 180, 255, 0.3);
195+
transform: translateY(-2px);
196+
}
197+
198+
.dialog-btn.cancel {
199+
background: rgba(255, 255, 255, 0.1);
200+
}
201+
.dialog-btn.cancel:hover {
202+
background: rgba(255, 255, 255, 0.2);
203+
transform: translateY(-2px);
204+
}
205+
206+
/* Transition — same as UnsavedDialog */
207+
.modal-enter-active { animation: backdrop-fade-in 0.25s ease forwards; }
208+
@keyframes backdrop-fade-in { from { opacity: 0; } to { opacity: 1; } }
209+
.modal-enter-active .dialog { animation: modal-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; }
210+
@keyframes modal-in {
211+
0% { opacity: 0; transform: scale(0.88) translateY(12px); }
212+
60% { opacity: 1; transform: scale(1.03) translateY(-2px); }
213+
80% { transform: scale(0.98) translateY(1px); }
214+
100% { opacity: 1; transform: scale(1) translateY(0); }
215+
}
216+
.modal-leave-active { transition: opacity 0.18s ease; }
217+
.modal-leave-active .dialog { animation: modal-out 0.18s ease-in forwards; }
218+
@keyframes modal-out {
219+
0% { opacity: 1; transform: scale(1); }
220+
100% { opacity: 0; transform: scale(0.92) translateY(6px); }
221+
}
222+
.modal-leave-to { opacity: 0; }
223+
</style>

app/components/Terminal.client.vue

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ onMounted(async () => {
4747
await nextTick();
4848
fitAddon.fit();
4949
50-
// WebSocket connection
51-
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
52-
ws = new WebSocket(`${protocol}//${window.location.host}/_terminal`);
50+
// WebSocket connection — local mode uses Nitro /_terminal (node-pty),
51+
// remote SSH mode connects directly to the Deno backend /_terminal (Deno PTY)
52+
const { getWsUrl } = useApi();
53+
ws = new WebSocket(getWsUrl());
5354
5455
ws.onopen = () => {
5556
ws!.send(JSON.stringify({

app/composables/useApi.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Centralized API helper that supports both local mode (through Nuxt proxy)
3+
* and remote SSH mode (proxied to a user-configured Deno backend URL).
4+
*
5+
* In local mode: requests go to /api/* on this Nuxt server
6+
* In remote mode: requests still go to /api/*, but the proxy reads the
7+
* X-Backend-Url header and forwards to the remote Deno instance
8+
*
9+
* Terminal WebSocket bypasses the proxy entirely in remote mode and connects
10+
* directly to the remote Deno backend's /_terminal endpoint.
11+
*/
12+
13+
function getStoredBackendUrl(): string {
14+
if (!import.meta.client) return "";
15+
return localStorage.getItem("locode:backendUrl") || "";
16+
}
17+
18+
export function useApi() {
19+
/**
20+
* Drop-in replacement for fetch() for all /api/* calls.
21+
* Automatically includes X-Backend-Url when a remote backend is configured.
22+
*/
23+
function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
24+
const backendUrl = getStoredBackendUrl();
25+
const headers: Record<string, string> = {
26+
...(options.headers as Record<string, string> || {}),
27+
};
28+
if (backendUrl) {
29+
headers["X-Backend-Url"] = backendUrl;
30+
}
31+
return fetch(`/api${path}`, { ...options, headers });
32+
}
33+
34+
/**
35+
* Returns the WebSocket URL for the terminal.
36+
* - Local mode: connects to Nitro /_terminal (node-pty, local shell)
37+
* - Remote mode: connects directly to remote Deno /_terminal (Deno PTY, remote shell)
38+
*/
39+
function getWsUrl(): string {
40+
if (!import.meta.client) return "";
41+
const backendUrl = getStoredBackendUrl();
42+
if (backendUrl) {
43+
return backendUrl.replace(/^http/, "ws") + "/_terminal";
44+
}
45+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
46+
return `${protocol}//${window.location.host}/_terminal`;
47+
}
48+
49+
return { apiFetch, getWsUrl, getStoredBackendUrl };
50+
}

app/composables/useLocodeConfig.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,15 @@ const DEFAULTS: LocodeConfig = {
4444
const configCache = new Map<string, LocodeConfig>()
4545

4646
export function useLocodeConfig() {
47+
const { apiFetch } = useApi()
48+
4749
function configPath(rootPath: string) {
4850
return rootPath + "/.LoCode"
4951
}
5052

5153
async function loadConfig(rootPath: string): Promise<LocodeConfig> {
5254
try {
53-
const res = await fetch("/api/read?path=" + encodeURIComponent(configPath(rootPath)))
55+
const res = await apiFetch("/read?path=" + encodeURIComponent(configPath(rootPath)))
5456
if (res.ok) {
5557
const text = await res.text()
5658
const parsed = JSON.parse(text)
@@ -71,7 +73,7 @@ export function useLocodeConfig() {
7173
// Update cache synchronously so concurrent calls see the latest state
7274
configCache.set(rootPath, merged)
7375
try {
74-
await fetch("/api/write", {
76+
await apiFetch("/write", {
7577
method: "POST",
7678
headers: { "Content-Type": "application/json" },
7779
body: JSON.stringify({

0 commit comments

Comments
 (0)