diff --git a/UI/git_sidebar/gitui.js b/UI/git_sidebar/gitui.js index 1fb38aa..cfc9502 100644 --- a/UI/git_sidebar/gitui.js +++ b/UI/git_sidebar/gitui.js @@ -8,12 +8,15 @@ class GitUi { this.workspace = null; this.state = { mode: 'empty', entries: [], commits: [], message: 'Open a folder to use Git.', messageType: 'info' }; this.listening = false; + this.refreshToken = 0; + this.commitMessage = ''; } async setWorkspace(workspace) { this.workspace = workspace || null; this.gitman.setWorkspace(this.workspace); if (!this.workspace) { + ++this.refreshToken; this.state = { mode: 'empty', entries: [], commits: [], message: 'Open a folder to use Git.', messageType: 'info' }; this.render(); return; @@ -26,7 +29,10 @@ class GitUi { this.listening = true; this.containerEl.addEventListener('click', (event) => this.handleClick(event)); this.containerEl.addEventListener('input', (event) => { - if (event.target.id === 'git-commit-message') this.renderActionState(); + if (event.target.id === 'git-commit-message') { + this.commitMessage = event.target.value; + this.renderActionState(); + } }); } @@ -46,6 +52,7 @@ class GitUi { this.setMessage(`${this.actionLabel(method)}…`, 'loading'); try { const output = await this.gitman[method](argument); + if (method === 'commit') this.commitMessage = ''; await this.refresh(`${this.actionLabel(method)} completed${output ? `: ${output}` : '.'}`); } catch (error) { this.setMessage(error.message || String(error), 'error'); @@ -58,6 +65,7 @@ class GitUi { async refresh(successMessage = '') { if (!this.workspace) return; + const refreshToken = ++this.refreshToken; this.state = { ...this.state, mode: 'loading', message: 'Loading repository status…', messageType: 'loading' }; this.render(); try { @@ -65,8 +73,10 @@ class GitUi { this.gitman.status(), typeof this.gitman.log === 'function' ? this.gitman.log().then((raw) => ({ raw }), (error) => ({ error: error?.message || String(error) })) : Promise.resolve({ raw: '' }) ]); + if (refreshToken !== this.refreshToken) return; this.state = { mode: 'repository', entries: StatusStub.parse(raw), commits: LogStub.parse(log.raw), historyError: log.error || '', message: successMessage || 'Repository status is up to date.', messageType: successMessage ? 'success' : 'info' }; } catch (error) { + if (refreshToken !== this.refreshToken) return; if (/not a git repository/i.test(error.message || '')) { this.state = { mode: 'non-repository', entries: [], commits: [], message: 'This folder is not a Git repository.', messageType: 'info' }; } else { @@ -147,7 +157,7 @@ class GitUi { history.append(item); } panel.append(history); - const input = this.create('input', { id: 'git-commit-message', className: 'git-commit-input', type: 'text', placeholder: 'Commit message', disabled: busy || !this.state.entries.length }); + const input = this.create('input', { id: 'git-commit-message', className: 'git-commit-input', type: 'text', placeholder: 'Commit message', value: this.commitMessage, disabled: busy || !this.state.entries.length }); input.setAttribute('aria-label', 'Commit message'); panel.append(input, this.button('Commit', 'commit', true)); const list = this.create('ul', { className: 'git-status-list' }); diff --git a/UI/git_sidebar/gitui.test.js b/UI/git_sidebar/gitui.test.js index f173d61..a62e29b 100644 --- a/UI/git_sidebar/gitui.test.js +++ b/UI/git_sidebar/gitui.test.js @@ -7,7 +7,7 @@ class FakeElement { append(...children) { this.children.push(...children); } replaceChildren(...children) { this.children = children; } setAttribute(name, value) { this[name] = value; } - addEventListener() {} + addEventListener(type, listener) { this.listeners ||= {}; this.listeners[type] = listener; } querySelector(selector) { const matches = (element) => selector.startsWith('#') ? element.id === selector.slice(1) : selector.startsWith('.') ? element.className?.split(' ').includes(selector.slice(1)) : selector === '[data-git-action="commit"]' && element.dataset.gitAction === 'commit'; const visit = (element) => matches(element) ? element : element.children.map(visit).find(Boolean); @@ -127,3 +127,58 @@ test('keeps status available on history failure and clears the error after recov assert.match(container.textContent, /No commits yet/); assert.doesNotMatch(container.textContent, /Unable to load commit history/); }); + + +test('only the newest refresh can apply success or failure, even after changing or clearing workspaces', async () => { + for (const staleFails of [false, true]) { + for (const nextWorkspace of ['/other', null]) { + let finish; + const pending = new Promise((resolve, reject) => { finish = () => staleFails ? reject(new Error('stale failure')) : resolve(' M stale.js'); }); + const gitman = { setWorkspace() {}, status: () => pending }; + const ui = new GitUi(createContainer(), { gitman }); + const oldRefresh = ui.setWorkspace('/workspace'); + gitman.status = async () => ' M current.js'; + await ui.setWorkspace(nextWorkspace); + const currentState = ui.state; + finish(); + await oldRefresh; + assert.equal(ui.state, currentState); + assert.equal(ui.state.mode, nextWorkspace ? 'repository' : 'empty'); + } + } + const pending = deferred(); + const gitman = { setWorkspace() {}, status: () => pending.promise }; + const ui = new GitUi(createContainer(), { gitman }); + const oldRefresh = ui.setWorkspace('/workspace'); + gitman.status = async () => ' M newest.js'; + await ui.refresh(); + pending.resolve(' M stale.js'); + await oldRefresh; + assert.match(ui.containerEl.textContent, /newest.js/); + assert.doesNotMatch(ui.containerEl.textContent, /stale.js/); +}); + +test('preserves commit input through loading and failure and clears it after a successful commit', async () => { + const container = createContainer(); + const pending = deferred(); + const gitman = { setWorkspace() {}, status: async () => ' M file.js', commit: () => pending.promise }; + const ui = new GitUi(container, { gitman }); + ui.initListeners(); + await ui.setWorkspace('/workspace'); + const input = container.querySelector('#git-commit-message'); + input.value = 'Keep this draft'; + container.listeners.input({ target: input }); + const action = ui.runAction('commit', input.value); + assert.equal(container.querySelector('#git-commit-message').value, 'Keep this draft'); + pending.resolve(''); + await action; + assert.equal(ui.commitMessage, ''); + assert.equal(container.querySelector('#git-commit-message').value, ''); + input.value = 'Retry this draft'; + container.listeners.input({ target: input }); + gitman.commit = async () => { throw new Error('commit rejected'); }; + await ui.runAction('commit', input.value); + await ui.refresh(); + assert.equal(ui.commitMessage, 'Retry this draft'); + assert.equal(container.querySelector('#git-commit-message').value, 'Retry this draft'); +}); diff --git a/docs/index.html b/docs/index.html index e1fd9d6..736fe5e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -14,6 +14,7 @@

SATISCODE

+ Web IDE About Versions Contributing diff --git a/docs/pages/about.html b/docs/pages/about.html index 70d722e..b207e2e 100644 --- a/docs/pages/about.html +++ b/docs/pages/about.html @@ -15,6 +15,7 @@

SATISCODE

🌙 Dark Mode Home + Web IDE Versions Contributing Sponsors diff --git a/docs/pages/contribute.html b/docs/pages/contribute.html index 10c0408..3e1fb3f 100644 --- a/docs/pages/contribute.html +++ b/docs/pages/contribute.html @@ -15,6 +15,7 @@

SATISCODE

🌙 Dark Mode Home + Web IDE Versions About Sponsors diff --git a/docs/pages/sponsors.html b/docs/pages/sponsors.html index e1a94d0..20630b2 100644 --- a/docs/pages/sponsors.html +++ b/docs/pages/sponsors.html @@ -15,6 +15,7 @@

SATISCODE

🌙 Dark Mode Home + Web IDE Versions About Contributing diff --git a/docs/pages/versions.html b/docs/pages/versions.html index 98cc5f3..f694882 100644 --- a/docs/pages/versions.html +++ b/docs/pages/versions.html @@ -15,6 +15,7 @@

SATISCODE

🌙 Dark Mode Home + Web IDE About Contributing Sponsors diff --git a/docs/webIDE.behavior.test.js b/docs/webIDE.behavior.test.js new file mode 100644 index 0000000..2c41b25 --- /dev/null +++ b/docs/webIDE.behavior.test.js @@ -0,0 +1,250 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const vm = require('node:vm'); +const { test } = require('node:test'); + +const webDir = path.join(__dirname, 'webIDE'); +const read = (file) => readFileSync(file, 'utf8'); + +class Element { + constructor() { this.listeners = {}; this.children = []; this.style = { setProperty() {} }; this.dataset = {}; this.classList = { add() {}, remove() {} }; } + addEventListener(type, listener) { this.listeners[type] = listener; } + setAttribute() {} + querySelector() { return null; } + append(...children) { this.children.push(...children); } + appendChild(child) { this.append(child); } + removeChild(child) { this.children = this.children.filter((item) => item !== child); } + replaceChildren(...children) { this.children = children; } + focus() {} + showModal() {} + click() { return this.onclick?.(); } +} + +function createDocument() { + const elements = new Map(); + const options = ['json', 'python', 'auto'].map((language) => Object.assign(new Element(), { dataset: { sourceLanguage: language } })); + const document = { + body: new Element(), documentElement: new Element(), + createElement: () => new Element(), + getElementById(id) { + if (!elements.has(id)) elements.set(id, new Element()); + return elements.get(id); + }, + querySelector: () => new Element(), + querySelectorAll: (selector) => selector === '[data-source-language]' ? options : [] + }; + return { document, options }; +} + +function createApi() { + const { document } = createDocument(); + const downloads = []; + const window = {}; + const errors = []; + const context = vm.createContext({ window, document, localStorage: { getItem: () => null }, alert() {}, console: { ...console, error: (...args) => errors.push(args) }, + URL: { createObjectURL: () => 'blob:test', revokeObjectURL() {} } }); + vm.runInContext(read(path.join(webDir, 'web-api.js')), context); + class JSZip { + constructor() { this.files = new Map(); downloads.push(this.files); } + file(name, content) { this.files.set(name, content); } + async generateAsync() { return {}; } + } + return { window, document, downloads, JSZip, errors }; +} + +test('ZIP saves report failures and persist existing and new files only as real file paths', async () => { + const { window, downloads, JSZip, errors } = createApi(); + const api = window.api; + assert.equal(await api.saveFile(), null); + assert.equal(await api.saveFile({ filePath: '/workspace/missing.txt' }), null); + assert.equal(await api.saveProjectZip(), false); + assert.equal(await api.saveFile({ filePath: '/workspace/new.txt', content: 'keep unsaved text' }), null); + window.JSZip = JSZip; + assert.equal(await api.saveFile({ filePath: '/workspace/new.txt', content: 'saved' }), '/workspace/new.txt'); + assert.equal(downloads.at(-1).get('workspace/new.txt'), 'saved'); + const first = await api.saveFile({ content: '' }); + const second = await api.saveFile({ content: 'second' }); + assert.match(first, /^\/workspace\/[^/]+\.txt$/); + assert.notEqual(first, second); + assert.equal((await api.readFile(second)).content, 'second'); + assert.equal(downloads.at(-1).get(second.slice(1)), 'second'); + assert.equal(await api.saveProjectZip(), true); + window.JSZip = class extends JSZip { async generateAsync() { throw new Error('export failed'); } }; + assert.equal(await api.saveFile({ filePath: first, content: 'retry me' }), null); + assert.equal((await api.readFile(first)).content, 'retry me'); + assert.equal(errors.length, 1); +}); + +test('directory fallback retains nested paths and duplicate basenames in exports and listings', async () => { + const { window, document, downloads, JSZip } = createApi(); + window.JSZip = JSZip; + document.createElement = (tag) => { + if (tag !== 'input') return new Element(); + const input = new Element(); + input.files = [ + { name: 'same.txt', webkitRelativePath: 'project/left/same.txt', text: async () => 'left' }, + { name: 'same.txt', webkitRelativePath: 'project/right/same.txt', text: async () => 'right' }, + { name: 'fallback.txt', text: async () => 'fallback' } + ]; + input.onclick = () => input.onchange(); + return input; + }; + assert.equal(await window.api.openFolder(), '/project'); + const left = await window.api.listDirectory('/project/left'); + assert.equal(left[0].name, 'same.txt'); + assert.equal(left[0].path, '/project/left/same.txt'); + await window.api.saveProjectZip(); + assert.equal(downloads.at(-1).get('project/left/same.txt'), 'left'); + assert.equal(downloads.at(-1).get('project/right/same.txt'), 'right'); + assert.equal(downloads.at(-1).get('project/fallback.txt'), 'fallback'); +}); + +test('Markdown fails closed when either dependency is missing and sanitizes parsed output', () => { + const source = read(path.join(webDir, 'app.js')); + const view = source.slice(source.indexOf('class WebMarkdownView'), source.indexOf('const translations')); + const markdown = ''; + for (const window of [{}, { marked: { parse() { throw new Error('must not parse'); } } }, { DOMPurify: { sanitize() { throw new Error('must not sanitize'); } } }]) { + const article = { set innerHTML(_) { throw new Error('unsafe HTML assignment'); } }; + vm.runInNewContext(`${view}\nnew WebMarkdownView(container).render(markdown);`, { window, markdown, container: { querySelector: () => article } }); + assert.equal(article.textContent, markdown); + } + const article = {}; + vm.runInNewContext(`${view}\nnew WebMarkdownView(container).render(markdown);`, { + window: { marked: { parse: (text) => { assert.equal(text, markdown); return 'parsed'; } }, DOMPurify: { sanitize: (html) => { assert.equal(html, 'parsed'); return 'clean'; } } }, + markdown, container: { querySelector: () => article } + }); + assert.equal(article.innerHTML, 'clean'); +}); + +function loadModule(file, globals, exports) { + const source = read(file).replace(/\bexport /g, '').replaceAll('import.meta.url', JSON.stringify(pathToFileURL(file).href)); + return vm.runInNewContext(`${source}\n({ ${exports.join(', ')} });`, { URL, console, ...globals }); +} + +test('root and shared loaders use module-relative URLs, reject HTTP failures, and resolve icon aliases', async () => { + for (const dir of [path.join(__dirname, '..'), path.join(webDir, 'shared')]) { + const iconsFile = path.join(dir, 'icons.js'); + const map = { ui: { newFile: 'fallback' }, extensions: { '.custom': 'custom' }, languages: { javascript: 'js', python: 'py', plaintext: 'txt', c: 'c' } }; + let ok = true; + const icons = loadModule(iconsFile, { fetch: async (url) => { + assert.equal(url.href, new URL('./icons/style1/icons.json', pathToFileURL(iconsFile)).href); + return { ok, status: 404, json: async () => { assert.equal(ok, true); return map; } }; + }, console: { error() {} } }, ['initIcons', 'getFileIcon']); + await icons.initIcons(); + for (const [file, expected] of [[null, 'fallback'], ['', 'fallback'], ['c', 'fallback'], ['README', 'fallback'], ['file.JS', 'js'], ['a.py', 'py'], ['a.txt', 'txt'], ['a.custom', 'custom'], ['a.c', 'c'], ['a.unknown', 'fallback']]) { + assert.equal(icons.getFileIcon(file), expected); + } + ok = false; + await icons.initIcons(); + assert.equal(icons.getFileIcon('a.js'), 'js'); + const themesFile = path.join(dir, 'themes.js'); + let calls = 0; + const theme = { colors: {} }; + const themes = loadModule(themesFile, { fetch: async (url) => { + calls++; + assert.equal(url.href, new URL('./themes/dark.json', pathToFileURL(themesFile)).href); + return { ok, status: 503, json: async () => { assert.equal(ok, true); return theme; } }; + } }, ['loadTheme']); + await assert.rejects(themes.loadTheme('dark'), /dark.*503/); + ok = true; + assert.equal(await themes.loadTheme('dark'), theme); + assert.equal(await themes.loadTheme('dark'), theme); + assert.equal(calls, 2); + } +}); + +async function createApp() { + const { window } = createApi(); + const { document: appDocument, options } = createDocument(); + const messages = []; + let starts = 0; + let ready; + let tabs; + let onChange; + let complete; + let start; + const started = new Promise((resolve) => { start = resolve; }); + window.api.startFixson = () => { starts++; return started; }; + const send = window.api.sendFixsonMessage; + window.api.sendFixsonMessage = (msg) => { messages.push(msg); send(msg); }; + const model = (text, language) => ({ getValue: () => text, setValue(value) { text = value; }, language }); + const editor = { setModel(value) { this.model = value; }, getModel() { return this.model; }, getValue() { return this.model.getValue(); }, onDidChangeModelContent(cb) { onChange = cb; } }; + const monaco = { Uri: { file: (file) => ({ toString: () => `file://${file}` }) }, MarkerSeverity: { Error: 8 }, editor: { create: () => editor, createModel: model, setTheme() {}, setModelLanguage: (model, language) => { model.language = language; }, setModelMarkers: (model, owner, markers) => { model.markers = markers; } }, languages: { CompletionItemKind: { Property: 1 }, registerCompletionItemProvider: (_, provider) => { complete = provider; } } }; + const tabSource = path.join(__dirname, '..', 'UI', 'tab_manager', 'tabman.js'); + const { TabManager } = loadModule(tabSource, { confirm: () => true }, ['TabManager']); + const require = (_, callback) => { ready = callback(monaco); }; + require.config = () => {}; + const source = read(path.join(webDir, 'app.js')).replace(/^import .*;$/gm, ''); + const context = vm.createContext({ window, document: appDocument, console, require, initIcons: async () => {}, getFileIcon: () => '', getFolderIcon() {}, getChevronIcon() {}, getUiIcon() {}, fetch: async () => ({ json: async () => ({ colors: {} }) }), TabManager: class extends TabManager { constructor(...args) { super(...args); tabs = this; } } }); + await vm.runInContext(`(async () => { ${source}\n })()`, context); + await ready; + return { window, document: appDocument, tabs, editor, options, model, messages, start, change: () => onChange(), complete: () => complete.provideCompletionItems(), starts: () => starts }; +} + +const tick = async () => { await new Promise((resolve) => setImmediate(resolve)); }; + +test('JSON tabs open independently and retain stable URIs, increasing versions, and tab-local diagnostics', async () => { + const app = await createApp(); + const first = app.tabs.getActiveTab(); + app.tabs.openTab('/workspace/second.json', app.model('{}', 'json')); + const second = app.tabs.getActiveTab(); + app.start(); + await tick(); + const opens = () => app.messages.filter((msg) => msg.method === 'textDocument/didOpen'); + assert.equal(app.starts(), 1); + assert.equal(opens().length, 2); + assert.ok(opens().every((msg) => msg.params.textDocument.version === 1)); + assert.notEqual(opens()[0].params.textDocument.uri, opens()[1].params.textDocument.uri); + for (const tab of [first, second, first]) { + app.tabs.setActive(tab.id); + tab.model.setValue('{'); + await app.change(); + } + const changes = app.messages.filter((msg) => msg.method === 'textDocument/didChange'); + assert.deepEqual(changes.map((msg) => msg.params.textDocument.version), [2, 2, 3]); + assert.equal(opens().length, 2); + app.options[1].listeners.click(); + assert.equal(first.model.language, 'python'); + app.tabs.setActive(second.id); + assert.equal(second.model.language, 'json'); + app.tabs.setActive(first.id); + assert.equal(first.model.language, 'json'); + app.tabs.openTab(null, app.model('', 'plaintext'), 'Untitled'); + app.options[0].listeners.click(); + await tick(); + const untitledUri = opens().at(-1).params.textDocument.uri; + assert.match(untitledUri, /^untitled:/); + await app.change(); + await app.complete(); + assert.equal(app.messages.at(-1).params.textDocument.uri, untitledUri); + assert.equal(opens().length, 3); + assert.equal(first.model.markers.length, 1); + assert.equal(second.model.markers.length, 1); + app.tabs.openTab(null, app.model('{}', 'plaintext'), 'Another untitled'); + app.options[0].listeners.click(); + await tick(); + assert.equal(opens().length, 4); + assert.notEqual(opens().at(-1).params.textDocument.uri, untitledUri); + assert.equal(opens().at(-1).params.textDocument.version, 1); +}); + + +test('failed exports keep a tab dirty and successful exports assign a real path', async () => { + const app = await createApp(); + app.start(); + await tick(); + const tab = app.tabs.getActiveTab(); + tab.isDirty = true; + assert.equal(await app.tabs.onSave(tab), false); + assert.equal(tab.isDirty, true); + app.window.JSZip = class { file() {} async generateAsync() { return {}; } }; + app.tabs.openTab(null, app.model('unsaved content', 'plaintext'), 'Untitled'); + const untitled = app.tabs.getActiveTab(); + untitled.isDirty = true; + assert.equal(await app.tabs.onSave(untitled), true); + assert.equal(untitled.isDirty, false); + assert.match(untitled.filePath, /\.txt$/); + assert.equal((await app.window.api.readFile(untitled.filePath)).content, 'unsaved content'); +}); diff --git a/docs/webIDE.test.js b/docs/webIDE.test.js new file mode 100644 index 0000000..46afb93 --- /dev/null +++ b/docs/webIDE.test.js @@ -0,0 +1,47 @@ +const assert = require("node:assert/strict"); +const { existsSync, readFileSync } = require("node:fs"); +const path = require("node:path"); +const { test } = require("node:test"); + +test("verifies Web IDE files and shared sync assets exist", () => { + const webIdeDir = path.join(__dirname, "webIDE"); + assert.ok(existsSync(path.join(webIdeDir, "index.html")), "index.html should exist"); + assert.ok(existsSync(path.join(webIdeDir, "style.css")), "style.css should exist"); + assert.ok(existsSync(path.join(webIdeDir, "app.js")), "app.js should exist"); + assert.ok(existsSync(path.join(webIdeDir, "web-api.js")), "web-api.js should exist"); + + const sharedDir = path.join(webIdeDir, "shared"); + assert.ok(existsSync(path.join(sharedDir, "styles", "style1.css")), "shared style1.css should exist"); + assert.ok(existsSync(path.join(sharedDir, "UI", "tab_manager", "tabman.js")), "shared TabManager should exist"); + assert.ok(existsSync(path.join(sharedDir, "icons.js")), "shared icons.js should exist"); + assert.ok(existsSync(path.join(sharedDir, "themes.js")), "shared themes.js should exist"); +}); + +test("verifies web-api.js browser translation layer exports required window.api methods and ZIP save", () => { + const apiJs = readFileSync(path.join(__dirname, "webIDE", "web-api.js"), "utf8"); + + assert.match(apiJs, /window\.api\s*=\s*\{/); + assert.match(apiJs, /openFile:/); + assert.match(apiJs, /readFile:/); + assert.match(apiJs, /saveFile:/); + assert.match(apiJs, /saveProjectZip:/); + assert.match(apiJs, /listDirectory:/); + assert.match(apiJs, /openFolder:/); + assert.match(apiJs, /getSettings:/); + assert.match(apiJs, /setUserName:/); + assert.match(apiJs, /setTheme:/); + assert.match(apiJs, /setLanguage:/); + assert.match(apiJs, /startFixson:/); + assert.match(apiJs, /textDocument\/publishDiagnostics/); +}); + +test("verifies docs/webIDE/index.html includes JSZip, removes Git sidebar elements, and loads scripts", () => { + const htmlContent = readFileSync(path.join(__dirname, "webIDE", "index.html"), "utf8"); + + assert.match(htmlContent, /jszip/i); + assert.match(htmlContent, / + + + + + + + + +
+ + + + + + Exit +
+ + +
+ +
+ + +
+ web: online + + + satiscode: Operational + +
+ +
+ + +
+
+ +
+ +
+
+
+ Problems + 0 +
+
No problems detected.
+
+
+
+ + + + +
+

Welcome to Satiscode Web IDE

+

What should we call you?

+ + +
+ + +
+
+
+ + diff --git a/docs/webIDE/shared/UI/git_sidebar/gitman.js b/docs/webIDE/shared/UI/git_sidebar/gitman.js new file mode 100644 index 0000000..b76a61d --- /dev/null +++ b/docs/webIDE/shared/UI/git_sidebar/gitman.js @@ -0,0 +1,52 @@ +class GitManager { + constructor(options = {}) { + this.api = options.api; + this.workspace = options.workspace || null; + } + + setWorkspace(workspace) { + this.workspace = workspace || null; + } + + async run(operation, ...args) { + if (!this.workspace) throw new Error('Open a folder to use Git.'); + if (!this.api || typeof this.api.run !== 'function') throw new Error('Git API is unavailable.'); + const result = await this.api.run(operation, this.workspace, args); + if (!result.ok) { + const error = new Error(result.stderr || result.stdout || 'Git command failed.'); + error.code = result.code; + throw error; + } + return result.stdout; + } + + async init() { + return this.run('init'); + } + + async addAll() { + return this.run('addAll'); + } + + async commit(message) { + return this.run('commit', message); + } + + async status() { + return this.run('status'); + } + + async log() { + return this.run('log'); + } + + async push() { + return this.run('push'); + } + + async pull() { + return this.run('pull'); + } +} + +module.exports = GitManager; diff --git a/docs/webIDE/shared/UI/git_sidebar/gitman.test.js b/docs/webIDE/shared/UI/git_sidebar/gitman.test.js new file mode 100644 index 0000000..1f3bea3 --- /dev/null +++ b/docs/webIDE/shared/UI/git_sidebar/gitman.test.js @@ -0,0 +1,24 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const GitManager = require('./gitman'); + +test('passes the selected workspace and arguments to the Git bridge', async () => { + let request; + const manager = new GitManager({ api: { run: async (...args) => { request = args; return { ok: true, stdout: 'done' }; } } }); + manager.setWorkspace('/selected/workspace'); + assert.equal(await manager.commit('message with spaces'), 'done'); + assert.deepEqual(request, ['commit', '/selected/workspace', ['message with spaces']]); +}); + +test('rejects structured bridge errors', async () => { + const manager = new GitManager({ workspace: '/workspace', api: { run: async () => ({ ok: false, stderr: 'failed', code: 1 }) } }); + await assert.rejects(manager.status(), /failed/); +}); + +test('requests commit history through the selected workspace', async () => { + let request; + const manager = new GitManager({ api: { run: async (...args) => { request = args; return { ok: true, stdout: 'history' }; } } }); + manager.setWorkspace('/selected/workspace'); + assert.equal(await manager.log(), 'history'); + assert.deepEqual(request, ['log', '/selected/workspace', []]); +}); diff --git a/docs/webIDE/shared/UI/git_sidebar/gitstub.js b/docs/webIDE/shared/UI/git_sidebar/gitstub.js new file mode 100644 index 0000000..4ee94fb --- /dev/null +++ b/docs/webIDE/shared/UI/git_sidebar/gitstub.js @@ -0,0 +1,53 @@ +class StatusStub { + static parse(raw) { + if (!raw || !raw.trim()) { + return []; + } + + return raw.split(/\r?\n/).filter(Boolean).map(line => { + const code = line.slice(0, 2); + const file = line.slice(2).trim(); + return { code, file }; + }); + } +} + +class BranchStub { + static parse(raw) { + const lines = raw.split("\n").filter(Boolean); + + let current = null; + const branches = []; + + for (const line of lines) { + if (line.startsWith("*")) { + current = line.replace(/^\*\s*/, "").trim(); + branches.push(current); + } else { + branches.push(line.trim()); + } + } + return { current, branches }; + } +} + +class LogStub { + static parse(raw) { + if (!raw || !raw.trim()) return []; + + return raw.split(/\r?\n/).flatMap((line) => { + const separator = line.indexOf('\x1f'); + if (separator < 0) return line.trim() ? [{ graph: line, graphOnly: true }] : []; + const [hash, parents, author, date, refs, message] = line.slice(separator + 1).split('\x1f'); + if (!hash || !author) return []; + return [{ graph: line.slice(0, separator), hash, parents: parents ? parents.split(' ').filter(Boolean) : [], author, date, refs: refs || '', message }]; + }); + } +} + + +module.exports = { + StatusStub, + BranchStub, + LogStub +}; diff --git a/docs/webIDE/shared/UI/git_sidebar/gitstub.test.js b/docs/webIDE/shared/UI/git_sidebar/gitstub.test.js new file mode 100644 index 0000000..14154d8 --- /dev/null +++ b/docs/webIDE/shared/UI/git_sidebar/gitstub.test.js @@ -0,0 +1,21 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { StatusStub } = require('./gitstub'); + +test('parses modified, added, deleted, renamed, and untracked files', () => { + assert.deepEqual(StatusStub.parse(' M modified.js\nA added.js\n D deleted.js\nR old.js -> new.js\n?? untracked.js'), [ + { code: ' M', file: 'modified.js' }, { code: 'A ', file: 'added.js' }, { code: ' D', file: 'deleted.js' }, + { code: 'R ', file: 'old.js -> new.js' }, { code: '??', file: 'untracked.js' } + ]); +}); + +test('preserves graph connector rows in commit order', () => { + const { LogStub } = require('./gitstub'); + const rows = LogStub.parse('* \x1fmerge\x1fleft right\x1fAda\x1ftoday\x1fHEAD -> main\x1fMerge\r\n|\\ \r\n| * \x1fright\x1f\x1fGrace\x1ftoday\x1f\x1fBranch work\r\n|/ \r\n\n'); + assert.equal(rows.length, 4); + assert.equal(rows[0].message, 'Merge'); + assert.deepEqual(rows[1], { graph: '|\\ ', graphOnly: true }); + assert.equal(rows[2].message, 'Branch work'); + assert.deepEqual(rows[3], { graph: '|/ ', graphOnly: true }); + assert.deepEqual(LogStub.parse(' \n'), []); +}); diff --git a/docs/webIDE/shared/UI/git_sidebar/gitui.js b/docs/webIDE/shared/UI/git_sidebar/gitui.js new file mode 100644 index 0000000..cfc9502 --- /dev/null +++ b/docs/webIDE/shared/UI/git_sidebar/gitui.js @@ -0,0 +1,184 @@ +const GitManager = require('./gitman'); +const { LogStub, StatusStub } = require('./gitstub'); + +class GitUi { + constructor(containerEl, options = {}) { + this.containerEl = containerEl; + this.gitman = options.gitman || new GitManager({ api: options.gitApi }); + this.workspace = null; + this.state = { mode: 'empty', entries: [], commits: [], message: 'Open a folder to use Git.', messageType: 'info' }; + this.listening = false; + this.refreshToken = 0; + this.commitMessage = ''; + } + + async setWorkspace(workspace) { + this.workspace = workspace || null; + this.gitman.setWorkspace(this.workspace); + if (!this.workspace) { + ++this.refreshToken; + this.state = { mode: 'empty', entries: [], commits: [], message: 'Open a folder to use Git.', messageType: 'info' }; + this.render(); + return; + } + await this.refresh(); + } + + initListeners() { + if (!this.containerEl || this.listening) return; + this.listening = true; + this.containerEl.addEventListener('click', (event) => this.handleClick(event)); + this.containerEl.addEventListener('input', (event) => { + if (event.target.id === 'git-commit-message') { + this.commitMessage = event.target.value; + this.renderActionState(); + } + }); + } + + async handleClick(event) { + const action = event.target.dataset?.gitAction; + if (!action) return; + if (action === 'refresh') return this.refresh(); + const operations = { init: 'init', stage: 'addAll', commit: 'commit', pull: 'pull', push: 'push' }; + const method = operations[action]; + if (!method) return; + const message = action === 'commit' ? this.containerEl.querySelector('#git-commit-message')?.value.trim() : undefined; + if (action === 'commit' && !message) return; + await this.runAction(method, message); + } + + async runAction(method, argument) { + this.setMessage(`${this.actionLabel(method)}…`, 'loading'); + try { + const output = await this.gitman[method](argument); + if (method === 'commit') this.commitMessage = ''; + await this.refresh(`${this.actionLabel(method)} completed${output ? `: ${output}` : '.'}`); + } catch (error) { + this.setMessage(error.message || String(error), 'error'); + } + } + + actionLabel(method) { + return { init: 'Initializing repository', addAll: 'Staging files', commit: 'Committing changes', pull: 'Pulling changes', push: 'Pushing changes' }[method]; + } + + async refresh(successMessage = '') { + if (!this.workspace) return; + const refreshToken = ++this.refreshToken; + this.state = { ...this.state, mode: 'loading', message: 'Loading repository status…', messageType: 'loading' }; + this.render(); + try { + const [raw, log] = await Promise.all([ + this.gitman.status(), + typeof this.gitman.log === 'function' ? this.gitman.log().then((raw) => ({ raw }), (error) => ({ error: error?.message || String(error) })) : Promise.resolve({ raw: '' }) + ]); + if (refreshToken !== this.refreshToken) return; + this.state = { mode: 'repository', entries: StatusStub.parse(raw), commits: LogStub.parse(log.raw), historyError: log.error || '', message: successMessage || 'Repository status is up to date.', messageType: successMessage ? 'success' : 'info' }; + } catch (error) { + if (refreshToken !== this.refreshToken) return; + if (/not a git repository/i.test(error.message || '')) { + this.state = { mode: 'non-repository', entries: [], commits: [], message: 'This folder is not a Git repository.', messageType: 'info' }; + } else { + this.state = { mode: 'error', entries: [], commits: [], message: error.message || String(error), messageType: 'error' }; + } + } + this.render(); + } + + setMessage(message, messageType) { + this.state.message = message; + this.state.messageType = messageType; + this.render(); + } + + create(tagName, properties = {}, text = '') { + const element = this.containerEl.ownerDocument.createElement(tagName); + Object.assign(element, properties); + if (text) element.textContent = text; + return element; + } + + button(label, action, disabled = false) { + const button = this.create('button', { type: 'button', disabled }, label); + button.dataset.gitAction = action; + return button; + } + + render() { + if (!this.containerEl || !this.containerEl.ownerDocument) return; + const panel = this.create('section', { className: 'git-panel' }); + panel.append(this.create('h2', { className: 'git-title' }, 'Source Control')); + const status = this.create('p', { className: `git-message git-message--${this.state.messageType}`, role: this.state.messageType === 'error' ? 'alert' : 'status' }, this.state.message); + panel.append(status); + + if (this.state.mode === 'non-repository') { + panel.append(this.button('Init repository', 'init')); + } else if (this.state.mode === 'repository' || this.state.mode === 'loading' || this.state.mode === 'error') { + const busy = this.state.mode === 'loading' || this.state.messageType === 'loading'; + const toolbar = this.create('div', { className: 'git-actions' }); + toolbar.append(this.button('Refresh', 'refresh', busy), this.button('Stage all', 'stage', busy || !this.state.entries.length), this.button('Pull', 'pull', busy), this.button('Push', 'push', busy)); + panel.append(toolbar); + panel.append(this.create('h3', { className: 'git-history-title' }, 'Commits')); + const history = this.create('ol', { className: 'git-history-list' }); + history.setAttribute('aria-label', 'Commit history'); + if (this.state.historyError) history.append(this.create('li', { className: 'git-message git-message--error', role: 'alert' }, `Unable to load commit history: ${this.state.historyError}`)); + else if (!this.state.commits.length) history.append(this.create('li', { className: 'git-history-empty' }, 'No commits yet')); + const graphWidth = Math.max(2, ...this.state.commits.map((commit) => (commit.graph || '*').length)); + for (const commit of this.state.commits) { + const item = this.create('li', { className: 'git-history-entry' }); + const graph = this.create('span', { className: 'git-commit-graph' }, commit.graph || '*'); + graph.setAttribute('aria-hidden', 'true'); + graph.style.width = `${graphWidth}ch`; + if (commit.graphOnly) { + item.className = 'git-history-entry git-history-connector'; + item.setAttribute('aria-hidden', 'true'); + item.append(graph); + history.append(item); + continue; + } + const commitColumn = (commit.graph || '*').indexOf('*'); + if (commitColumn >= 0) { + graph.className += ' git-commit-graph--commit'; + graph.style.setProperty('--commit-column', `${commitColumn + 0.5}ch`); + } + const details = this.create('details', { className: 'git-commit-details' }); + const summary = this.create('summary', { className: 'git-commit-summary' }); + const message = this.create('span', { className: 'git-commit-message', title: commit.message }, commit.message || '(no message)'); + const meta = this.create('span', { className: 'git-commit-meta' }, `${commit.hash.slice(0, 7)} · ${commit.date}${commit.refs ? ` · ${commit.refs}` : ''}`); + summary.append(message, meta); + details.append(summary); + const info = this.create('div', { className: 'git-commit-info' }); + info.append(this.create('span', {}, `Author: ${commit.author}`), this.create('span', {}, `Commit: ${commit.hash}`)); + if (commit.parents.length) info.append(this.create('span', {}, `Parents: ${commit.parents.map((parent) => parent.slice(0, 7)).join(', ')}`)); + if (commit.refs) info.append(this.create('span', {}, `Refs: ${commit.refs}`)); + details.append(info); + item.append(graph, details); + history.append(item); + } + panel.append(history); + const input = this.create('input', { id: 'git-commit-message', className: 'git-commit-input', type: 'text', placeholder: 'Commit message', value: this.commitMessage, disabled: busy || !this.state.entries.length }); + input.setAttribute('aria-label', 'Commit message'); + panel.append(input, this.button('Commit', 'commit', true)); + const list = this.create('ul', { className: 'git-status-list' }); + list.setAttribute('aria-label', 'Changed files'); + if (!this.state.entries.length) list.append(this.create('li', { className: 'git-clean' }, 'No changes')); + for (const entry of this.state.entries) { + const item = this.create('li', { className: 'git-status-entry' }); + item.append(this.create('span', { className: 'git-status-code' }, entry.code), this.create('span', { className: 'git-status-file', title: entry.file }, entry.file)); + list.append(item); + } + panel.append(list); + } + this.containerEl.replaceChildren(panel); + this.renderActionState(); + } + + renderActionState() { + const input = this.containerEl?.querySelector?.('#git-commit-message'); + const commit = this.containerEl?.querySelector?.('[data-git-action="commit"]'); + if (input && commit) commit.disabled = input.disabled || !input.value.trim(); + } +} + +module.exports = { GitUi }; diff --git a/docs/webIDE/shared/UI/git_sidebar/gitui.test.js b/docs/webIDE/shared/UI/git_sidebar/gitui.test.js new file mode 100644 index 0000000..a62e29b --- /dev/null +++ b/docs/webIDE/shared/UI/git_sidebar/gitui.test.js @@ -0,0 +1,184 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { GitUi } = require('./gitui'); + +class FakeElement { + constructor(tagName, document) { this.tagName = tagName; this.ownerDocument = document; this.children = []; this.dataset = {}; this.disabled = false; this.value = ''; this.style = { setProperty(name, value) { this[name] = value; } }; } + append(...children) { this.children.push(...children); } + replaceChildren(...children) { this.children = children; } + setAttribute(name, value) { this[name] = value; } + addEventListener(type, listener) { this.listeners ||= {}; this.listeners[type] = listener; } + querySelector(selector) { + const matches = (element) => selector.startsWith('#') ? element.id === selector.slice(1) : selector.startsWith('.') ? element.className?.split(' ').includes(selector.slice(1)) : selector === '[data-git-action="commit"]' && element.dataset.gitAction === 'commit'; + const visit = (element) => matches(element) ? element : element.children.map(visit).find(Boolean); + return visit(this); + } + get textContent() { return this._text || this.children.map((child) => child.textContent).join(' '); } + set textContent(value) { this._text = value; } +} + +function createContainer() { + const document = { createElement: (tagName) => new FakeElement(tagName, document) }; + return new FakeElement('div', document); +} + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test('renders empty, loading, repository, success, non-repository, and error states', async () => { + const container = createContainer(); + const pending = deferred(); + const gitman = { setWorkspace() {}, status: () => pending.promise }; + const ui = new GitUi(container, { gitman }); + ui.render(); + assert.match(container.textContent, /Open a folder/); + const refresh = ui.setWorkspace('/workspace'); + assert.match(container.textContent, /Loading repository status/); + pending.resolve(' M changed.js\n?? new.js'); + await refresh; + assert.match(container.textContent, /changed\.js/); + ui.setMessage('Staging files completed.', 'success'); + assert.match(container.textContent, /completed/); + gitman.status = async () => { throw new Error('fatal: not a git repository'); }; + await ui.refresh(); + assert.match(container.textContent, /Init repository/); + gitman.status = async () => { throw new Error('permission denied'); }; + await ui.refresh(); + assert.match(container.textContent, /permission denied/); +}); + +test('actions invoke the expected operation and refresh status', async () => { + let stageCalls = 0; + let statusCalls = 0; + const gitman = { setWorkspace() {}, status: async () => { statusCalls++; return ' M file.js'; }, addAll: async () => { stageCalls++; return ''; } }; + const ui = new GitUi(createContainer(), { gitman }); + await ui.setWorkspace('/workspace'); + await ui.runAction('addAll'); + assert.equal(stageCalls, 1); + assert.equal(statusCalls, 2); + assert.equal(ui.state.messageType, 'success'); +}); + +test('renders graph-aware, expandable commit history', async () => { + const gitman = { + setWorkspace() {}, + status: async () => '', + log: async () => '* \x1f0123456789abcdef\x1f\x1fAda Lovelace\x1f2 hours ago\x1f\x1fAdd feature\n| * \x1fabcdef9876543210\x1f0123456789abcdef 1111111111111111\x1fGrace Hopper\x1f1 hour ago\x1fHEAD -> main\x1fMerge branch' + }; + const container = createContainer(); + const ui = new GitUi(container, { gitman }); + await ui.setWorkspace('/workspace'); + assert.equal(ui.state.commits.length, 2); + assert.equal(ui.state.commits[0].message, 'Add feature'); + assert.match(container.querySelector('.git-commit-summary').textContent, /^Add feature /); + assert.equal(ui.state.commits[1].parents.length, 2); + assert.match(container.textContent, /Add feature/); + assert.match(container.textContent, /Merge branch/); + assert.match(container.textContent, /Parents: 0123456, 1111111/); + assert.match(container.textContent, /\| \*/); +}); + +test('clearing the workspace clears repository state', async () => { + const ui = new GitUi(createContainer(), { gitman: { setWorkspace() {}, status: async () => '' } }); + await ui.setWorkspace('/workspace'); + await ui.setWorkspace(null); + assert.equal(ui.state.mode, 'empty'); + assert.deepEqual(ui.state.entries, []); +}); + +test('renders connector rows in order and positions commit markers in their graph lanes', async () => { + const container = createContainer(); + const ui = new GitUi(container, { gitman: { + setWorkspace() {}, status: async () => '', + log: async () => '* \x1fmerge\x1fleft right\x1fAda\x1ftoday\x1f\x1fMerge\n|\\ \n| * \x1fright\x1f\x1fGrace\x1ftoday\x1f\x1fBranch work\n|/ ' + } }); + await ui.setWorkspace('/workspace'); + const rows = container.querySelector('.git-history-list').children; + assert.equal(rows.length, 4); + assert.equal(rows[0].children[0].style['--commit-column'], '0.5ch'); + assert.equal(rows[2].children[0].style['--commit-column'], '2.5ch'); + assert.equal(rows[1].textContent, '|\\ '); + assert.equal(rows[3].textContent, '|/ '); + assert.equal(rows[1].children.length, 1); + assert.equal(rows[1].children[0].style['--commit-column'], undefined); + for (const row of rows) assert.equal(row.children[0].style.width, '4ch'); +}); + +test('keeps status available on history failure and clears the error after recovery', async () => { + const container = createContainer(); + const gitman = { + setWorkspace() {}, status: async () => ' M changed.js', + log: async () => { throw new Error('history permission denied'); } + }; + const ui = new GitUi(container, { gitman }); + await ui.setWorkspace('/workspace'); + assert.equal(ui.state.mode, 'repository'); + assert.equal(ui.state.historyError, 'history permission denied'); + assert.match(container.textContent, /changed.js/); + assert.match(container.textContent, /Unable to load commit history: history permission denied/); + assert.doesNotMatch(container.textContent, /No commits yet/); + assert.equal(container.querySelector('.git-message--error').role, 'alert'); + gitman.log = async () => ''; + await ui.refresh(); + assert.equal(ui.state.historyError, ''); + assert.match(container.textContent, /No commits yet/); + assert.doesNotMatch(container.textContent, /Unable to load commit history/); +}); + + +test('only the newest refresh can apply success or failure, even after changing or clearing workspaces', async () => { + for (const staleFails of [false, true]) { + for (const nextWorkspace of ['/other', null]) { + let finish; + const pending = new Promise((resolve, reject) => { finish = () => staleFails ? reject(new Error('stale failure')) : resolve(' M stale.js'); }); + const gitman = { setWorkspace() {}, status: () => pending }; + const ui = new GitUi(createContainer(), { gitman }); + const oldRefresh = ui.setWorkspace('/workspace'); + gitman.status = async () => ' M current.js'; + await ui.setWorkspace(nextWorkspace); + const currentState = ui.state; + finish(); + await oldRefresh; + assert.equal(ui.state, currentState); + assert.equal(ui.state.mode, nextWorkspace ? 'repository' : 'empty'); + } + } + const pending = deferred(); + const gitman = { setWorkspace() {}, status: () => pending.promise }; + const ui = new GitUi(createContainer(), { gitman }); + const oldRefresh = ui.setWorkspace('/workspace'); + gitman.status = async () => ' M newest.js'; + await ui.refresh(); + pending.resolve(' M stale.js'); + await oldRefresh; + assert.match(ui.containerEl.textContent, /newest.js/); + assert.doesNotMatch(ui.containerEl.textContent, /stale.js/); +}); + +test('preserves commit input through loading and failure and clears it after a successful commit', async () => { + const container = createContainer(); + const pending = deferred(); + const gitman = { setWorkspace() {}, status: async () => ' M file.js', commit: () => pending.promise }; + const ui = new GitUi(container, { gitman }); + ui.initListeners(); + await ui.setWorkspace('/workspace'); + const input = container.querySelector('#git-commit-message'); + input.value = 'Keep this draft'; + container.listeners.input({ target: input }); + const action = ui.runAction('commit', input.value); + assert.equal(container.querySelector('#git-commit-message').value, 'Keep this draft'); + pending.resolve(''); + await action; + assert.equal(ui.commitMessage, ''); + assert.equal(container.querySelector('#git-commit-message').value, ''); + input.value = 'Retry this draft'; + container.listeners.input({ target: input }); + gitman.commit = async () => { throw new Error('commit rejected'); }; + await ui.runAction('commit', input.value); + await ui.refresh(); + assert.equal(ui.commitMessage, 'Retry this draft'); + assert.equal(container.querySelector('#git-commit-message').value, 'Retry this draft'); +}); diff --git a/docs/webIDE/shared/UI/markdown_view/style.css b/docs/webIDE/shared/UI/markdown_view/style.css new file mode 100644 index 0000000..fb140ad --- /dev/null +++ b/docs/webIDE/shared/UI/markdown_view/style.css @@ -0,0 +1,148 @@ +#markdown-mode-controls { + display: flex; + align-items: center; + gap: 2px; + padding: 4px 8px; + border-bottom: 1px solid var(--vscode-border); + background: var(--vscode-sidebar-bg); +} + +#markdown-mode-controls[hidden] { + display: none; +} + +#markdown-mode-controls button { + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--vscode-text-muted); + font: inherit; + cursor: pointer; +} + +#markdown-mode-controls button:hover { + background: var(--vscode-hover-item); + color: var(--vscode-text-main); +} + +#markdown-mode-controls button[aria-pressed="true"] { + border-color: var(--vscode-border); + background: var(--vscode-active-item); + color: var(--vscode-text-active); +} + +#markdown-preview { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--vscode-bg); +} + +.md-view-content { + width: min(100%, 920px); + margin: 0 auto; + padding: 28px 36px 56px; + color: var(--vscode-text-main); + font-size: 14px; + line-height: 1.7; + user-select: text; + overflow-wrap: anywhere; +} + +.md-view-content > :first-child { + margin-top: 0; +} + +.md-view-content h1, +.md-view-content h2, +.md-view-content h3, +.md-view-content h4, +.md-view-content h5, +.md-view-content h6 { + color: var(--vscode-text-active); + line-height: 1.3; + margin: 1.7em 0 0.65em; +} + +.md-view-content h1 { + padding-bottom: 0.35em; + border-bottom: 1px solid var(--vscode-border); + font-size: 2em; +} + +.md-view-content h2 { + padding-bottom: 0.3em; + border-bottom: 1px solid var(--vscode-border); + font-size: 1.55em; +} + +.md-view-content a { + color: var(--vscode-focus-border); +} + +.md-view-content blockquote { + margin: 1em 0; + padding: 0.15em 1em; + color: var(--vscode-text-muted); + border-left: 3px solid var(--vscode-focus-border); + background: var(--vscode-sidebar-bg); +} + +.md-view-content :not(pre) > code { + padding: 0.15em 0.4em; + border-radius: 3px; + background: var(--vscode-input-bg); + font-family: var(--font-mono); + font-size: 0.92em; +} + +.md-view-content pre { + overflow: auto; + padding: 16px; + border: 1px solid var(--vscode-border); + border-radius: 4px; + background: var(--vscode-sidebar-bg); + line-height: 1.55; +} + +.md-view-content pre code { + font-family: var(--font-mono); + font-size: 13px; + tab-size: 2; +} + +.md-view-content table { + width: 100%; + border-collapse: collapse; + display: block; + overflow-x: auto; +} + +.md-view-content th, +.md-view-content td { + padding: 7px 12px; + border: 1px solid var(--vscode-border); + text-align: left; +} + +.md-view-content th { + color: var(--vscode-text-active); + background: var(--vscode-sidebar-bg); +} + +.md-view-content img { + max-width: 100%; + height: auto; +} + +.md-view-content hr { + border: 0; + border-top: 1px solid var(--vscode-border); +} + +@media (max-width: 640px) { + .md-view-content { + padding: 22px 18px 40px; + } +} diff --git a/docs/webIDE/shared/UI/tab_manager/tabman.js b/docs/webIDE/shared/UI/tab_manager/tabman.js new file mode 100644 index 0000000..19aa2c2 --- /dev/null +++ b/docs/webIDE/shared/UI/tab_manager/tabman.js @@ -0,0 +1,144 @@ +/* +Warning: This file has been AI vibecoded +*/ + +export class TabManager { + constructor(containerEl, onTabChange, onSave) { + this.containerEl = containerEl; + this.tabs = []; + this.activeTabId = null; + this.onTabChange = onTabChange; // Callback when active tab swaps + this.onSave = onSave; // Callback when a dirty tab should be saved + this.onNewTabRequest = null; // Callback when '+' button is clicked + + this.initEventListeners(); + } + + initEventListeners() { + this.containerEl.addEventListener('click', (e) => { + // Check if '+' button was clicked + if (e.target.id === 'add-tab-btn' || e.target.closest('#add-tab-btn')) { + if (this.onNewTabRequest) this.onNewTabRequest(); + return; + } + + const tabEl = e.target.closest('[data-tab-id]'); + if (!tabEl) return; + + const id = tabEl.dataset.tabId; + if (e.target.classList.contains('close-btn')) { + e.stopPropagation(); + this.closeTab(id); + } else { + this.setActive(id); + } + }); + + this.containerEl.addEventListener('auxclick', (e) => { + if (e.button === 1) { // Middle click to close + const tabEl = e.target.closest('[data-tab-id]'); + if (tabEl) this.closeTab(tabEl.dataset.tabId); + } + }); + } + + openTab(filePath, model, title) { + let existing = this.tabs.find(t => t.filePath && t.filePath === filePath); + if (existing) { + this.setActive(existing.id); + return; + } + + const newTab = { + id: 'tab_' + Date.now() + '_' + Math.random().toString(36).substr(2, 4), + filePath: filePath || null, + title: title || (filePath ? filePath.split(/[\\/]/).pop() : 'Untitled'), + model: model, + isDirty: false + }; + + this.tabs.push(newTab); + this.setActive(newTab.id); + } + + setActive(id) { + this.activeTabId = id; + const tab = this.tabs.find(t => t.id === id); + this.render(); + if (this.onTabChange) this.onTabChange(tab || null); + } + + async closeTab(id) { + const index = this.tabs.findIndex(t => t.id === id); + if (index === -1) return; + + const tab = this.tabs[index]; + if (tab.isDirty) { + if (this.onSave) { + if (confirm(`Save changes to ${tab.title}?`)) { + const saved = await this.onSave(tab); + if (saved === false) return; + } else if (!confirm('Discard changes?')) { + return; + } + } else if (!confirm('Discard changes?')) { + return; + } + } + + if (tab.model && typeof tab.model.dispose === 'function') { + tab.model.dispose(); + } + this.tabs.splice(index, 1); + + if (this.activeTabId === id) { + const nextTab = this.tabs[index] || this.tabs[index - 1]; + this.activeTabId = nextTab ? nextTab.id : null; + if (nextTab) { + this.setActive(nextTab.id); + } else { + this.render(); + if (this.onTabChange) this.onTabChange(null); + } + } else { + this.render(); + } + } + + getActiveTab() { + return this.tabs.find(t => t.id === this.activeTabId) || null; + } + + getTabByFilePath(filePath) { + return this.tabs.find(t => t.filePath && t.filePath === filePath) || null; + } + + render() { + if (!this.containerEl) return; + + const tabsHtml = this.tabs.map(tab => ` +
+ + +
+ `).join(''); + + // Append the add tab button at the end + this.containerEl.innerHTML = tabsHtml + ` + + `; + } +} + +function escapeHtml(value) { + return String(value).replace(/[&<>'"]/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"' + }[char])); +} + +// TODO: Needs fixing so module exports work. +// module.exports = TabManager; diff --git a/docs/webIDE/shared/icons.js b/docs/webIDE/shared/icons.js new file mode 100644 index 0000000..284ce89 --- /dev/null +++ b/docs/webIDE/shared/icons.js @@ -0,0 +1,41 @@ +let iconMap = {}; + +export async function initIcons() { + try { + const response = await fetch(new URL('./icons/style1/icons.json', import.meta.url)); + if (!response.ok) throw new Error(`Failed to load icons.json: HTTP ${response.status}`); + iconMap = await response.json(); + } catch (err) { + console.error('Failed to load icons.json:', err); + } +} + +export function getFileIcon(fileName) { + const fallback = iconMap.ui?.newFile || '▱'; + if (!fileName || !fileName.includes('.')) return fallback; + const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase(); + const aliases = { js: 'javascript', jsx: 'javascript', mjs: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', txt: 'plaintext', md: 'markdown' }; + return ( + iconMap.extensions?.[`.${ext}`] || + iconMap.languages?.[aliases[ext] || ext] || + fallback + ); +} + +export function getFolderIcon(isExpanded = false) { + if (isExpanded) { + return iconMap.ui?.openFolder || iconMap.ui?.folder || '▰'; + } + return iconMap.ui?.folder || '▰'; +} + +export function getChevronIcon(isExpanded = false) { + if (isExpanded) { + return iconMap.ui?.chevronDown || '⌄'; + } + return iconMap.ui?.chevronRight || '›'; +} + +export function getUiIcon(iconName) { + return iconMap.ui?.[iconName] || ''; +} diff --git a/docs/webIDE/shared/icons/style1/icons.json b/docs/webIDE/shared/icons/style1/icons.json new file mode 100644 index 0000000..d36ca33 --- /dev/null +++ b/docs/webIDE/shared/icons/style1/icons.json @@ -0,0 +1,21 @@ +{ + "theme": "style1", + "ui": { + "newFile": "", + "folder": "", + "openFolder": "", + "save": "", + "translate": "" + }, + "languages": { + "c": "", + "cpp": "", + "css": "", + "h": "", + "hpp": "", + "html": "", + "javascript": "", + "plaintext": "", + "python": "" + } +} diff --git a/docs/webIDE/shared/styles/style1.css b/docs/webIDE/shared/styles/style1.css new file mode 100644 index 0000000..aace989 --- /dev/null +++ b/docs/webIDE/shared/styles/style1.css @@ -0,0 +1,738 @@ +/* ========================================================================== + VS CODE DARK PLUS THEME - Satiscode Layout + ========================================================================== */ + +:root { + /* Colors */ + --vscode-bg: #1e1e1e; + --vscode-sidebar-bg: #252526; + --vscode-toolbar-bg: #333333; + --vscode-input-bg: #3c3c3c; + --vscode-active-item: #37373d; + --vscode-hover-item: #2a2d2e; + --vscode-border: #2b2b2b; + --vscode-focus-border: #007fd4; + + /* Text Colors */ + --vscode-text-main: #cccccc; + --vscode-text-muted: #858585; + --vscode-text-active: #ffffff; + + /* Status & Diagnostics */ + --vscode-error: #f14c4c; + --vscode-warning: #cca700; + --vscode-badge-bg: #4d4d4d; + + /* Typography */ + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "Consolas", "Courier New", monospace; +} + +/* Reset & Base Setup */ +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + height: 100vh; + width: 100vw; + overflow: hidden; + background-color: var(--vscode-bg); + color: var(--vscode-text-main); + font-family: var(--font-ui); + font-size: 13px; + user-select: none; +} + +body { + display: flex; + flex-direction: column; +} + +/* ========================================================================== + TOP TOOLBAR / MENU BAR + ========================================================================== */ + +#toolbar { + display: flex; + align-items: center; + gap: 6px; + height: 38px; + padding: 0 10px; + background-color: var(--vscode-toolbar-bg); + border-bottom: 1px solid var(--vscode-border); + z-index: 10; +} + +#toolbar button, +#toolbar select { + background-color: transparent; + color: var(--vscode-text-main); + border: 1px solid transparent; + border-radius: 4px; + padding: 4px 10px; + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; + transition: background-color 0.15s ease; +} + +#toolbar button:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--vscode-text-active); +} + +#toolbar button:active { + background-color: rgba(255, 255, 255, 0.15); +} + +#toolbar select { + background-color: var(--vscode-input-bg); + border: 1px solid var(--vscode-border); + outline: none; +} + +#toolbar select:focus { + border-color: var(--vscode-focus-border); +} + +#status { + margin-left: auto; + font-family: var(--font-mono); + font-size: 11px; + color: var(--vscode-text-muted); + padding: 2px 8px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +/* Source Language Custom Menu */ +#source-language-menu { + position: relative; +} + +.language-badge { + display: inline-block; + background-color: var(--vscode-badge-bg); + color: var(--vscode-text-active); + font-family: var(--font-mono); + font-size: 10px; + font-weight: bold; + padding: 1px 5px; + border-radius: 3px; + margin-right: 6px; +} + +#source-language-options { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background-color: var(--vscode-sidebar-bg); + border: 1px solid var(--vscode-border); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + border-radius: 4px; + min-width: 140px; + display: flex; + flex-direction: column; + padding: 4px 0; + z-index: 100; +} + +#source-language-options[hidden] { + display: none; +} + +#language-menu { position: relative; } +#language-button { width: 32px; height: 28px; display: grid; place-items: center; padding: 4px !important; } +#language-button-icon, #language-button-icon svg { display: block; width: 16px; height: 16px; } +#language-options { + position: absolute; + top: 100%; + right: 0; + z-index: 100; + display: flex; + flex-direction: column; + min-width: 130px; + margin-top: 4px; + padding: 4px 0; + background: var(--vscode-sidebar-bg); + border: 1px solid var(--vscode-border); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); +} +#language-options[hidden] { display: none; } +#language-options button { width: 100%; text-align: left; border-radius: 0; } +#language-options button[aria-checked="true"] { color: var(--vscode-text-active); background: var(--vscode-active-item); } + +.source-language-option { + display: flex; + align-items: center; + width: 100%; + text-align: left; + border-radius: 0 !important; + padding: 6px 12px !important; +} + +.source-language-option:hover { + background-color: var(--vscode-hover-item) !important; +} + +/* ========================================================================== + MAIN CONTENT LAYOUT + ========================================================================== */ + +#content { + display: flex; + flex: 1; + height: calc(100vh - 38px); + overflow: hidden; +} + +#activity-bar { + width: 46px; + flex: 0 0 46px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 8px 0; + background-color: var(--vscode-bg); + border-right: 1px solid var(--vscode-border); +} + +.activity-button { + width: 38px; + height: 42px; + border: 0; + border-left: 2px solid transparent; + background: transparent; + color: var(--vscode-text-muted); + font-size: 21px; + cursor: pointer; +} + +.activity-button:hover, +.activity-button[aria-pressed="true"] { + color: var(--vscode-text-active); +} + +.activity-button[aria-pressed="true"] { + border-left-color: var(--vscode-focus-border); +} + +.activity-button:focus-visible { + outline: 1px solid var(--vscode-focus-border); +} + +/* Sidebar File Tree */ +#file-tree { + width: 250px; + min-width: 150px; + max-width: 400px; + background-color: var(--vscode-sidebar-bg); + border-right: 1px solid var(--vscode-border); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +#file-tree[hidden], +#git-sidebar[hidden] { + display: none; +} + +#tree-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--vscode-text-muted); + padding: 10px 20px 8px 12px; + letter-spacing: 0.5px; +} + +#tree-root { + font-size: 13px; + padding-left: 0; +} + +#git-sidebar { + width: min(260px, 28vw); + min-width: 190px; + padding: 10px; + background-color: var(--vscode-sidebar-bg); + border-right: 1px solid var(--vscode-border); + overflow-y: auto; +} + +#git-sidebar input, +#git-sidebar button { + width: 100%; +} + +.tree-entry { + display: flex; + align-items: center; + padding: 4px 12px; + cursor: pointer; + color: var(--vscode-text-main); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-entry:hover { + background-color: var(--vscode-hover-item); +} + +.tree-chevron { + width: 16px; + display: inline-block; + text-align: center; + font-size: 12px; + color: var(--vscode-text-muted); +} + +.tree-icon { + margin-right: 6px; + font-size: 12px; + color: var(--vscode-text-muted); +} + +.tree-children { + padding-left: 12px; +} + +/* Workspace & Editor Layout */ +#workspace { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + background-color: var(--vscode-bg); +} + +#editor { + flex: 1; + width: 100%; + height: 100%; +} + +/* ========================================================================== + PROBLEMS PANEL + ========================================================================== */ + +#problems-panel { + display: flex; + flex-direction: column; + height: 160px; + background-color: var(--vscode-bg); + border-top: 1px solid var(--vscode-border); + position: relative; +} + +#problems-resizer { + height: 4px; + width: 100%; + cursor: ns-resize; + position: absolute; + top: -2px; + left: 0; + z-index: 10; +} + +#problems-resizer:hover { + background-color: var(--vscode-focus-border); +} + +#problems-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background-color: var(--vscode-sidebar-bg); + border-bottom: 1px solid var(--vscode-border); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: bold; +} + +#problems-count { + background-color: var(--vscode-badge-bg); + color: var(--vscode-text-active); + font-size: 10px; + border-radius: 10px; + padding: 1px 6px; +} + +#problems-list { + flex: 1; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 12px; +} + +#no-problems { + padding: 12px; + color: var(--vscode-text-muted); +} + +.problem { + display: flex; + align-items: center; + padding: 4px 12px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.problem:hover { + background-color: var(--vscode-hover-item); +} + +.problem-icon { + font-weight: bold; + margin-right: 8px; + color: var(--vscode-error); +} + +.problem.warning .problem-icon { + color: var(--vscode-warning); +} + +.problem-message { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--vscode-text-main); +} + +.problem-location { + color: var(--vscode-text-muted); + margin-left: 12px; +} + +.language-badge svg, +.tree-icon svg { + width: 16px; + height: 16px; + display: inline-block; + vertical-align: middle; + fill: currentColor; /* Inherits the parent text color */ +} + +/* ========================================================================== + STATUS BAR ITEMS + ========================================================================== */ + +#status { + margin-left: auto; + font-family: var(--font-mono); + font-size: 11px; + color: var(--vscode-text-muted); + padding: 2px 8px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +#ideStatus { + font-family: var(--font-mono); + font-size: 11px; + color: var(--vscode-text-main); + padding: 2px 8px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +#ideStatus::before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + margin-right: 6px; + border-radius: 50%; + background-color: var(--status-starting); +} + +#ideStatus[data-state="operational"]::before { + background-color: #4ec9b0; +} + +#ideStatus[data-state="error"]::before { + background-color: var(--status-error); +} + +#ideStatus[data-state="offline"]::before { + background-color: var(--status-offline); +} + +/* styles of Ctrl + F */ +/* styles of Ctrl + F */ +#find-dialog { + position: fixed; + top: 44px; + right: 20px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 6px; + background-color: var(--vscode-sidebar-bg); + border: 1px solid var(--vscode-border); + border-radius: 4px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + z-index: 9999; + font-family: var(--font-ui); + font-size: 12px; +} + +#find-dialog.hidden { + display: none; +} + +#find-input { + background-color: var(--vscode-input-bg); + color: var(--vscode-text-main); + border: 1px solid var(--vscode-border); + border-radius: 3px; + padding: 4px 8px; + font-size: 12px; + font-family: var(--font-ui); + outline: none; + width: 180px; +} + +#find-input:focus { + border-color: var(--vscode-focus-border); +} + +#find-dialog button { + background-color: transparent; + color: var(--vscode-text-main); + border: 1px solid transparent; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; + transition: background-color 0.15s ease; +} + +#find-dialog button:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--vscode-text-active); +} + +#find-dialog button:active { + background-color: rgba(255, 255, 255, 0.15); +} + +#find-match-count { + font-family: var(--font-mono); + font-size: 11px; + color: var(--vscode-text-muted); + padding: 2px 8px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; + white-space: nowrap; + min-width: 60px; + text-align: center; +} + +.find-match-highlight { + background-color: rgba(0, 127, 212, 0.35); +} + +/* ========================================================================== + TAB BAR (VS Code Dark Plus Theme) + ========================================================================== */ + +#tab-bar, .tab-bar { + display: flex; + align-items: center; + background-color: var(--vscode-sidebar-bg); + border-bottom: 1px solid var(--vscode-border); + overflow-x: auto; + overflow-y: hidden; + user-select: none; + height: 35px; + min-height: 35px; +} + +/* Hide scrollbar for a clean tab bar appearance */ +#tab-bar::-webkit-scrollbar { + height: 3px; +} + +#tab-bar::-webkit-scrollbar-thumb { + background: var(--vscode-border); +} + +.tab { + display: flex; + align-items: center; + gap: 8px; + height: 100%; + padding: 0 12px; + background-color: var(--vscode-sidebar-bg); + color: var(--vscode-text-muted); + border-right: 1px solid var(--vscode-border); + border-top: 2px solid transparent; + cursor: pointer; + font-size: 13px; + white-space: nowrap; + transition: background-color 0.1s ease, color 0.1s ease; +} + +.tab:hover { + background-color: var(--vscode-hover-item); + color: var(--vscode-text-main); +} + +.tab.active { + background-color: var(--vscode-bg); + color: var(--vscode-text-active); + border-top-color: var(--vscode-focus-border); + border-bottom: 1px solid var(--vscode-bg); +} + +.tab .tab-title { + display: flex; + align-items: center; + align-self: stretch; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + font: inherit; + padding: 0; +} + +.tab .close-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + width: 18px; + height: 18px; + font-size: 14px; + line-height: 1; + color: var(--vscode-text-muted); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.tab .close-btn:hover { + background-color: rgba(255, 255, 255, 0.15); + color: var(--vscode-text-active); +} + +.add-tab-btn { + background: transparent; + border: none; + color: var(--vscode-text-muted); + font-size: 16px; + padding: 0 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.add-tab-btn:hover { + background-color: var(--vscode-hover-item); + color: var(--vscode-text-active); +} + +.add-tab-btn:focus-visible { + outline: 2px solid var(--vscode-focus-border); + outline-offset: -2px; +} + +.git-panel { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.git-title { margin: 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; } +.git-message { margin: 0; padding: 7px; border-radius: 3px; color: var(--vscode-text-muted); overflow-wrap: anywhere; } +.git-message--success { color: #89d185; } +.git-message--error { color: #f48771; } +.git-message--loading { color: var(--vscode-text-main); } +.git-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } +.git-panel button, .git-commit-input { box-sizing: border-box; min-height: 30px; border: 1px solid var(--vscode-border); border-radius: 4px; padding: 5px 8px; font: 12px var(--font-ui); } +.git-panel button { background-color: var(--vscode-toolbar-bg); color: var(--vscode-text-main); cursor: pointer; } +.git-panel button:hover:not(:disabled) { background-color: var(--vscode-hover-item); } +.git-panel button:disabled { cursor: default; opacity: 0.5; } +.git-panel button:focus-visible, .git-commit-input:focus-visible { outline: 2px solid var(--vscode-focus-border); outline-offset: 1px; } +.git-commit-input { + background: var(--vscode-editor-bg); + color: var(--vscode-text-main); +} +.git-history-title { margin: 6px 0 2px; font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--vscode-text-muted); } +.git-history-list { margin: 0; padding: 0; list-style: none; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 2px; } +.git-history-entry { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; min-width: 0; position: relative; padding: 1px 0; border-radius: 4px; } +.git-commit-graph--commit::before { content: ''; position: absolute; left: var(--commit-column); top: 0; bottom: 0; width: 1px; background: rgba(128, 128, 128, 0.45); } +.git-history-entry:hover { background: var(--vscode-list-hoverBackground); } +.git-commit-graph { position: relative; z-index: 1; display: block; min-height: 1.4em; align-self: stretch; padding: 3px 2px 0 0; color: var(--vscode-text-muted); white-space: pre; font: 11px/1.4 var(--font-mono); letter-spacing: normal; text-align: left; } +.git-commit-graph--commit::after { content: ''; position: absolute; left: var(--commit-column); top: 7px; transform: translateX(-50%); width: 7px; height: 7px; border-radius: 50%; background: var(--vscode-charts-green); box-shadow: 0 0 0 2px var(--vscode-sidebar-bg); } +.git-commit-details { flex: 1; min-width: 0; } +.git-commit-summary { position: relative; display: flex; flex-direction: column; gap: 1px; padding: 2px 8px 2px 0; border-radius: 4px; cursor: pointer; list-style: none; } +.git-commit-summary::-webkit-details-marker { display: none; } +.git-commit-summary::before { content: none; } +.git-commit-details[open] .git-commit-summary::before { content: none; } +.git-commit-summary:focus-visible { outline: 1px solid var(--vscode-focus-border); outline-offset: -1px; border-radius: 4px; } +.git-commit-message { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1.35; } +.git-commit-meta { display: block; color: var(--vscode-text-muted); font-size: 10px; margin-top: 1px; } +.git-commit-info { display: grid; gap: 3px; padding: 0 8px 7px 0; color: var(--vscode-text-muted); font-size: 10px; overflow-wrap: anywhere; } +.git-history-empty { padding: 6px 2px; color: var(--vscode-text-muted); } +.git-status-list { margin: 4px 0 0; padding: 0; list-style: none; overflow-y: auto; } +.git-status-entry { display: flex; gap: 8px; padding: 5px 2px; min-width: 0; } +.git-status-code { flex: 0 0 22px; color: #cca700; font-family: monospace; } +.git-status-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.git-clean { color: var(--vscode-text-muted); padding: 6px 2px; } + +#blame-dialog { + width: min(900px, calc(100vw - 32px)); + max-height: 78vh; + padding: 0; + border: 1px solid var(--vscode-border); + background: var(--vscode-sidebar-bg); + color: var(--vscode-text-main); +} + +#blame-dialog::backdrop { background: rgba(0, 0, 0, 0.55); } +.blame-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--vscode-border); } +.blame-header h2 { margin: 0; font-size: 13px; font-weight: 600; } +.blame-header button { width: 28px; height: 28px; border: 0; background: transparent; color: inherit; font-size: 20px; cursor: pointer; } +#blame-output { max-height: calc(78vh - 50px); margin: 0; padding: 12px 14px; overflow: auto; color: var(--vscode-text-main); font: 12px/1.5 var(--font-mono); user-select: text; } + +#name-setup-dialog { + width: min(380px, calc(100vw - 32px)); + border: 1px solid var(--vscode-border); + background: var(--vscode-sidebar-bg); + color: var(--vscode-text-main); +} + +#name-setup-dialog::backdrop { background: rgba(0, 0, 0, 0.6); } +#name-setup-dialog form { display: grid; gap: 10px; } +#name-setup-dialog h2, #name-setup-dialog p { margin: 0; } +#name-setup-dialog input { padding: 7px 9px; border: 1px solid var(--vscode-border); background: var(--vscode-input-bg); color: var(--vscode-text-main); font: inherit; } +#name-setup-dialog input:focus { outline: 1px solid var(--vscode-focus-border); } +.name-setup-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; } +.name-setup-actions button { padding: 6px 12px; border: 1px solid var(--vscode-border); background: var(--vscode-toolbar-bg); color: var(--vscode-text-main); cursor: pointer; } + +@media (max-width: 760px) { + #file-tree { width: 190px; min-width: 150px; } + #git-sidebar { width: 190px; min-width: 160px; padding: 7px; } + .git-actions { grid-template-columns: 1fr; } + #blame-dialog { width: calc(100vw - 20px); } +} diff --git a/docs/webIDE/shared/themes.js b/docs/webIDE/shared/themes.js new file mode 100644 index 0000000..dde8f4e --- /dev/null +++ b/docs/webIDE/shared/themes.js @@ -0,0 +1,29 @@ +const AVAILABLE_THEMES = ['dark', 'light']; +const themeCache = new Map(); + +export async function loadTheme(themeId) { + if (themeCache.has(themeId)) return themeCache.get(themeId); + const response = await fetch(new URL(`./themes/${themeId}.json`, import.meta.url)); + if (!response.ok) throw new Error(`Failed to load theme ${themeId}: HTTP ${response.status}`); + const theme = await response.json(); + themeCache.set(themeId, theme); + return theme; +} + +export async function applyTheme(themeId) { + const theme = await loadTheme(themeId); + + // 1. Apply colors to UI elements + const root = document.documentElement; + Object.entries(theme.colors).forEach(([key, value]) => { + root.style.setProperty(`--${key}`, value); + }); + + // 2. Monaco theme + if (window.monaco) monaco.editor.setTheme(theme.monacoTheme); + +} + +export async function getAvailableThemes() { + return Promise.all(AVAILABLE_THEMES.map(loadTheme)); +} diff --git a/docs/webIDE/shared/themes/dark.json b/docs/webIDE/shared/themes/dark.json new file mode 100644 index 0000000..0d34ffd --- /dev/null +++ b/docs/webIDE/shared/themes/dark.json @@ -0,0 +1,21 @@ +{ + "id": "dark", + "name": "Dark Theme", + "monacoTheme": "vs-dark", + "colors": { + "vscode-bg": "#1e1e1e", + "vscode-sidebar-bg": "#252526", + "vscode-toolbar-bg": "#333333", + "vscode-input-bg": "#3c3c3c", + "vscode-active-item": "#37373d", + "vscode-hover-item": "#2a2d2e", + "vscode-border": "#2b2b2b", + "vscode-focus-border": "#007fd4", + "vscode-text-main": "#cccccc", + "vscode-text-muted": "#858585", + "vscode-text-active": "#ffffff", + "vscode-error": "#f14c4c", + "vscode-warning": "#cca700", + "vscode-badge-bg": "#4d4d4d" + } +} diff --git a/docs/webIDE/shared/themes/high_contrast.json b/docs/webIDE/shared/themes/high_contrast.json new file mode 100644 index 0000000..cc07f17 --- /dev/null +++ b/docs/webIDE/shared/themes/high_contrast.json @@ -0,0 +1,21 @@ +{ + "id": "high_contrast", + "name": "High Contrast Theme", + "monacoTheme": "hc-black", + "colors": { + "vscode-bg": "#000000", + "vscode-sidebar-bg": "#000000", + "vscode-toolbar-bg": "#000000", + "vscode-input-bg": "#000000", + "vscode-active-item": "#ffffff", + "vscode-hover-item": "#1a1a1a", + "vscode-border": "#ffffff", + "vscode-focus-border": "#ffffff", + "vscode-text-main": "#ffffff", + "vscode-text-muted": "#bfbfbf", + "vscode-text-active": "#ffffff", + "vscode-error": "#ff0000", + "vscode-warning": "#ffff00", + "vscode-badge-bg": "#ffffff" + } +} diff --git a/docs/webIDE/shared/themes/light.json b/docs/webIDE/shared/themes/light.json new file mode 100644 index 0000000..7c83bb4 --- /dev/null +++ b/docs/webIDE/shared/themes/light.json @@ -0,0 +1,21 @@ +{ + "id": "light", + "name": "Light Theme", + "monacoTheme": "vs", + "colors": { + "vscode-bg": "#ffffff", + "vscode-sidebar-bg": "#f3f3f3", + "vscode-toolbar-bg": "#dddddd", + "vscode-input-bg": "#ffffff", + "vscode-active-item": "#e4e6f1", + "vscode-hover-item": "#e8e8e8", + "vscode-border": "#cecece", + "vscode-focus-border": "#0090f1", + "vscode-text-main": "#3b3b3b", + "vscode-text-muted": "#6f6f6f", + "vscode-text-active": "#000000", + "vscode-error": "#e51400", + "vscode-warning": "#bf8803", + "vscode-badge-bg": "#c4c4c4" + } +} diff --git a/docs/webIDE/style.css b/docs/webIDE/style.css new file mode 100644 index 0000000..55a44d7 --- /dev/null +++ b/docs/webIDE/style.css @@ -0,0 +1,659 @@ +/* ========================================================================== + SATISCODE WEB IDE STYLES + ========================================================================== */ + +:root { + /* Colors - Dark Theme (Default) */ + --vscode-bg: #1e1e1e; + --vscode-sidebar-bg: #252526; + --vscode-toolbar-bg: #333333; + --vscode-input-bg: #3c3c3c; + --vscode-active-item: #37373d; + --vscode-hover-item: #2a2d2e; + --vscode-border: #2b2b2b; + --vscode-focus-border: #007fd4; + + /* Text Colors */ + --vscode-text-main: #cccccc; + --vscode-text-muted: #858585; + --vscode-text-active: #ffffff; + + /* Status & Diagnostics */ + --vscode-error: #f14c4c; + --vscode-warning: #cca700; + --vscode-badge-bg: #4d4d4d; + --status-starting: #d7ba7d; + --status-error: #f14c4c; + --status-offline: #858585; + + /* Typography */ + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "Consolas", "Courier New", monospace; +} + +[data-theme="light"] { + --vscode-bg: #ffffff; + --vscode-sidebar-bg: #f3f3f3; + --vscode-toolbar-bg: #dddddd; + --vscode-input-bg: #ffffff; + --vscode-active-item: #e4e6f1; + --vscode-hover-item: #e8e8e8; + --vscode-border: #cccccc; + --vscode-focus-border: #0090f1; + + --vscode-text-main: #333333; + --vscode-text-muted: #707070; + --vscode-text-active: #000000; + + --vscode-error: #e51400; + --vscode-warning: #bf8800; + --vscode-badge-bg: #c0c0c0; +} + +/* Reset & Base Setup */ +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + height: 100vh; + width: 100vw; + overflow: hidden; + background-color: var(--vscode-bg); + color: var(--vscode-text-main); + font-family: var(--font-ui); + font-size: 13px; + user-select: none; +} + +body { + display: flex; + flex-direction: column; +} + +/* ========================================================================== + TOP TOOLBAR / MENU BAR + ========================================================================== */ + +#toolbar { + display: flex; + align-items: center; + gap: 6px; + height: 38px; + padding: 0 10px; + background-color: var(--vscode-toolbar-bg); + border-bottom: 1px solid var(--vscode-border); + z-index: 10; +} + +#toolbar button, +#toolbar select, +.toolbar-link { + background-color: transparent; + color: var(--vscode-text-main); + border: 1px solid transparent; + border-radius: 4px; + padding: 4px 10px; + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + transition: background-color 0.15s ease; +} + +#toolbar button:hover, +.toolbar-link:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--vscode-text-active); +} + +#toolbar button:active { + background-color: rgba(255, 255, 255, 0.15); +} + +#toolbar select { + background-color: var(--vscode-input-bg); + border: 1px solid var(--vscode-border); + outline: none; +} + +#toolbar select:focus { + border-color: var(--vscode-focus-border); +} + +#status { + margin-left: auto; + font-family: var(--font-mono); + font-size: 11px; + color: var(--vscode-text-muted); + padding: 2px 8px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +#ideStatus { + font-family: var(--font-mono); + font-size: 11px; + color: var(--vscode-text-main); + padding: 2px 8px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; + display: inline-flex; + align-items: center; +} + +.status-indicator { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 6px; + border-radius: 50%; + background-color: #4ec9b0; +} + +/* Source Language Custom Menu */ +#source-language-menu { + position: relative; +} + +.language-badge { + display: inline-block; + background-color: var(--vscode-badge-bg); + color: var(--vscode-text-active); + font-family: var(--font-mono); + font-size: 10px; + font-weight: bold; + padding: 1px 5px; + border-radius: 3px; + margin-right: 6px; +} + +#source-language-options { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background-color: var(--vscode-sidebar-bg); + border: 1px solid var(--vscode-border); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + border-radius: 4px; + min-width: 140px; + display: flex; + flex-direction: column; + padding: 4px 0; + z-index: 100; +} + +#source-language-options[hidden] { + display: none; +} + +.source-language-option { + display: flex; + align-items: center; + width: 100%; + text-align: left; + border-radius: 0 !important; + padding: 6px 12px !important; +} + +.source-language-option:hover { + background-color: var(--vscode-hover-item) !important; +} + +#language-menu { position: relative; } +#language-button { width: 32px; height: 28px; display: grid; place-items: center; padding: 4px !important; } +#language-options { + position: absolute; + top: 100%; + right: 0; + z-index: 100; + display: flex; + flex-direction: column; + min-width: 130px; + margin-top: 4px; + padding: 4px 0; + background: var(--vscode-sidebar-bg); + border: 1px solid var(--vscode-border); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); +} +#language-options[hidden] { display: none; } +#language-options button { width: 100%; text-align: left; border-radius: 0; } +#language-options button[aria-checked="true"] { color: var(--vscode-text-active); background: var(--vscode-active-item); } + +/* ========================================================================== + MAIN CONTENT LAYOUT + ========================================================================== */ + +#content { + display: flex; + flex: 1; + height: calc(100vh - 38px); + overflow: hidden; +} + +#activity-bar { + width: 46px; + flex: 0 0 46px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 8px 0; + background-color: var(--vscode-bg); + border-right: 1px solid var(--vscode-border); +} + +.activity-button { + width: 38px; + height: 42px; + border: 0; + border-left: 2px solid transparent; + background: transparent; + color: var(--vscode-text-muted); + font-size: 21px; + cursor: pointer; +} + +.activity-button:hover, +.activity-button[aria-pressed="true"] { + color: var(--vscode-text-active); +} + +.activity-button[aria-pressed="true"] { + border-left-color: var(--vscode-focus-border); +} + +/* Sidebar File Tree */ +#file-tree { + width: 250px; + min-width: 150px; + max-width: 400px; + background-color: var(--vscode-sidebar-bg); + border-right: 1px solid var(--vscode-border); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +#file-tree[hidden], +#git-sidebar[hidden] { + display: none; +} + +#tree-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--vscode-text-muted); + padding: 10px 20px 8px 12px; + letter-spacing: 0.5px; +} + +#tree-root { + font-size: 13px; + padding-left: 0; +} + +#git-sidebar { + width: min(260px, 28vw); + min-width: 190px; + padding: 10px; + background-color: var(--vscode-sidebar-bg); + border-right: 1px solid var(--vscode-border); + overflow-y: auto; +} + +.tree-entry { + display: flex; + align-items: center; + padding: 4px 12px; + cursor: pointer; + color: var(--vscode-text-main); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-entry:hover { + background-color: var(--vscode-hover-item); +} + +.tree-chevron { + width: 16px; + display: inline-block; + text-align: center; + font-size: 12px; + color: var(--vscode-text-muted); +} + +.tree-icon { + margin-right: 6px; + font-size: 12px; + color: var(--vscode-text-muted); +} + +.tree-children { + padding-left: 12px; +} + +/* Workspace & Editor Layout */ +#workspace { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + background-color: var(--vscode-bg); +} + +#editor { + flex: 1; + width: 100%; + height: 100%; +} + +/* ========================================================================== + TAB BAR + ========================================================================== */ + +#tab-bar, .tab-bar { + display: flex; + align-items: center; + background-color: var(--vscode-sidebar-bg); + border-bottom: 1px solid var(--vscode-border); + overflow-x: auto; + overflow-y: hidden; + user-select: none; + height: 35px; + min-height: 35px; +} + +.tab { + display: flex; + align-items: center; + gap: 8px; + height: 100%; + padding: 0 12px; + background-color: var(--vscode-sidebar-bg); + color: var(--vscode-text-muted); + border-right: 1px solid var(--vscode-border); + border-top: 2px solid transparent; + cursor: pointer; + font-size: 13px; + white-space: nowrap; + transition: background-color 0.1s ease, color 0.1s ease; +} + +.tab:hover { + background-color: var(--vscode-hover-item); + color: var(--vscode-text-main); +} + +.tab.active { + background-color: var(--vscode-bg); + color: var(--vscode-text-active); + border-top-color: var(--vscode-focus-border); + border-bottom: 1px solid var(--vscode-bg); +} + +.tab .tab-title { + display: flex; + align-items: center; + align-self: stretch; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + font: inherit; + padding: 0; +} + +.tab .close-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 3px; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + width: 18px; + height: 18px; + font-size: 14px; + line-height: 1; + color: var(--vscode-text-muted); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.tab .close-btn:hover { + background-color: rgba(255, 255, 255, 0.15); + color: var(--vscode-text-active); +} + +.add-tab-btn { + background: transparent; + border: none; + color: var(--vscode-text-muted); + font-size: 16px; + padding: 0 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.add-tab-btn:hover { + background-color: var(--vscode-hover-item); + color: var(--vscode-text-active); +} + +/* ========================================================================== + MARKDOWN PREVIEW & CONTROLS + ========================================================================== */ + +#markdown-mode-controls { + display: flex; + gap: 4px; + padding: 6px 12px; + background-color: var(--vscode-sidebar-bg); + border-bottom: 1px solid var(--vscode-border); +} + +#markdown-mode-controls button { + background-color: transparent; + color: var(--vscode-text-muted); + border: 1px solid var(--vscode-border); + border-radius: 3px; + padding: 3px 10px; + font-size: 12px; + cursor: pointer; +} + +#markdown-mode-controls button[aria-pressed="true"] { + background-color: var(--vscode-focus-border); + color: #ffffff; + border-color: var(--vscode-focus-border); +} + +#markdown-preview { + flex: 1; + overflow-y: auto; + padding: 20px 30px; + background-color: var(--vscode-bg); + color: var(--vscode-text-main); + font-family: var(--font-ui); + line-height: 1.6; +} + +#markdown-preview pre { + background-color: var(--vscode-sidebar-bg); + padding: 12px; + border-radius: 4px; + overflow-x: auto; + font-family: var(--font-mono); +} + +/* ========================================================================== + PROBLEMS PANEL + ========================================================================== */ + +#problems-panel { + display: flex; + flex-direction: column; + height: 160px; + background-color: var(--vscode-bg); + border-top: 1px solid var(--vscode-border); + position: relative; +} + +#problems-resizer { + height: 4px; + width: 100%; + cursor: ns-resize; + position: absolute; + top: -2px; + left: 0; + z-index: 10; +} + +#problems-resizer:hover { + background-color: var(--vscode-focus-border); +} + +#problems-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background-color: var(--vscode-sidebar-bg); + border-bottom: 1px solid var(--vscode-border); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: bold; +} + +#problems-count { + background-color: var(--vscode-badge-bg); + color: var(--vscode-text-active); + font-size: 10px; + border-radius: 10px; + padding: 1px 6px; +} + +#problems-list { + flex: 1; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 12px; +} + +#no-problems { + padding: 12px; + color: var(--vscode-text-muted); +} + +/* Find Dialog */ +#find-dialog { + position: fixed; + top: 44px; + right: 20px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 6px; + background-color: var(--vscode-sidebar-bg); + border: 1px solid var(--vscode-border); + border-radius: 4px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + z-index: 9999; + font-family: var(--font-ui); + font-size: 12px; +} + +#find-dialog.hidden { + display: none; +} + +#find-input { + background-color: var(--vscode-input-bg); + color: var(--vscode-text-main); + border: 1px solid var(--vscode-border); + border-radius: 3px; + padding: 4px 8px; + font-size: 12px; + font-family: var(--font-ui); + outline: none; + width: 180px; +} + +#find-dialog button { + background-color: transparent; + color: var(--vscode-text-main); + border: 1px solid transparent; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + cursor: pointer; +} + +#find-match-count { + font-family: var(--font-mono); + font-size: 11px; + color: var(--vscode-text-muted); + padding: 2px 8px; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 3px; + white-space: nowrap; +} + +.find-match-highlight { + background-color: rgba(0, 127, 212, 0.35); +} + +/* Modals */ +#blame-dialog, #name-setup-dialog { + width: min(420px, calc(100vw - 32px)); + border: 1px solid var(--vscode-border); + background: var(--vscode-sidebar-bg); + color: var(--vscode-text-main); + border-radius: 6px; + padding: 16px; +} + +#blame-dialog::backdrop, #name-setup-dialog::backdrop { + background: rgba(0, 0, 0, 0.6); +} + +.blame-header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--vscode-border); + padding-bottom: 8px; + margin-bottom: 12px; +} + +.blame-header h2 { margin: 0; font-size: 14px; } +.blame-header button { background: transparent; border: none; color: inherit; font-size: 18px; cursor: pointer; } +#blame-output { font-family: var(--font-mono); font-size: 12px; overflow-x: auto; margin: 0; } + +#name-setup-dialog form { display: grid; gap: 10px; } +#name-setup-dialog input { padding: 7px 9px; border: 1px solid var(--vscode-border); background: var(--vscode-input-bg); color: var(--vscode-text-main); border-radius: 4px; } +.name-setup-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; } +.name-setup-actions button { padding: 6px 12px; border: 1px solid var(--vscode-border); background: var(--vscode-toolbar-bg); color: var(--vscode-text-main); border-radius: 4px; cursor: pointer; } + +/* Git Panel */ +.git-panel { display: flex; flex-direction: column; gap: 8px; min-width: 0; } +.git-title { margin: 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; } +.git-message { margin: 0; padding: 7px; border-radius: 3px; color: var(--vscode-text-muted); overflow-wrap: anywhere; } +.git-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } +.git-panel button, .git-commit-input { box-sizing: border-box; min-height: 30px; border: 1px solid var(--vscode-border); border-radius: 4px; padding: 5px 8px; font: 12px var(--font-ui); } +.git-panel button { background-color: var(--vscode-toolbar-bg); color: var(--vscode-text-main); cursor: pointer; } +.git-commit-input { background: var(--vscode-input-bg); color: var(--vscode-text-main); } diff --git a/docs/webIDE/web-api.js b/docs/webIDE/web-api.js new file mode 100644 index 0000000..6234e2a --- /dev/null +++ b/docs/webIDE/web-api.js @@ -0,0 +1,340 @@ +/* ========================================================================== + SATISCODE WEB IDE API TRANSLATION LAYER + Translates Electron IPC APIs (window.api) to Browser APIs & Virtual Filesystem + ========================================================================== */ + +(function () { + 'use strict'; + + const SETTINGS_KEY = 'satiscode-web-settings'; + const DEFAULT_SETTINGS = { + language: 'en', + theme: 'dark', + userName: '', + namePromptCompleted: false + }; + + function loadSettings() { + try { + const stored = localStorage.getItem(SETTINGS_KEY); + return stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : { ...DEFAULT_SETTINGS }; + } catch { + return { ...DEFAULT_SETTINGS }; + } + } + + function saveSettings(settings) { + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + } catch (e) { + console.warn('Unable to save settings to localStorage:', e); + } + return settings; + } + + // Virtual Filesystem in memory + const virtualWorkspace = { + path: '/workspace', + name: 'workspace', + files: new Map([ + ['/workspace/main.cpp', { name: 'main.cpp', path: '/workspace/main.cpp', content: '#include \n\nint main() {\n std::cout << "Hello from Satiscode Web IDE!" << std::endl;\n return 0;\n}\n', isDirectory: false }], + ['/workspace/script.py', { name: 'script.py', path: '/workspace/script.py', content: 'def main():\n print("Hello from Python in Satiscode Web IDE!")\n\nif __name__ == "__main__":\n main()\n', isDirectory: false }], + ['/workspace/index.html', { name: 'index.html', path: '/workspace/index.html', content: '\n\n\n Satiscode Sample\n\n\n

Satiscode Web IDE

\n\n\n', isDirectory: false }], + ['/workspace/style.css', { name: 'style.css', path: '/workspace/style.css', content: 'body {\n font-family: sans-serif;\n background: #1e1e1e;\n color: #cccccc;\n}\n', isDirectory: false }], + ['/workspace/package.json', { name: 'package.json', path: '/workspace/package.json', content: '{\n "name": "satiscode-project",\n "version": "1.0.0"\n}\n', isDirectory: false }], + ['/workspace/app.js', { name: 'app.js', path: '/workspace/app.js', content: 'console.log("Satiscode Web IDE initialized.");\n', isDirectory: false }], + ['/workspace/README.md', { name: 'README.md', path: '/workspace/README.md', content: '# Satiscode Web IDE\n\nWelcome to Satiscode Web IDE running in your browser.\n\n## Features\n- C/C++, Python, Web Dev, JSON, and Markdown support\n- Tabbed editing using Monaco\n- Live Markdown preview\n- In-browser FIXSON JSON LSP validation & auto-completion\n- Project export as ZIP archive\n', isDirectory: false }] + ]) + }; + + let activeWorkspacePath = '/workspace'; + + // --- ZIP Save Functionality --- + async function saveProjectAsZip() { + if (!window.JSZip) { + alert('JSZip library is loading. Please try again in a moment.'); + return false; + } + + const zip = new window.JSZip(); + for (const [filePath, file] of virtualWorkspace.files.entries()) { + if (!file.isDirectory) { + const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath; + zip.file(relativePath, file.content); + } + } + + const blob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'satiscode-project.zip'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + return true; + } + + // --- In-Browser FIXSON JS Language Service --- + let fixsonDiagnosticsListener = null; + + function validateJsonDocument(uri, text) { + const diagnostics = []; + try { + JSON.parse(text); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ + severity: 1, // Error + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: Math.min(100, text.length || 1) } + }, + message: `FIXSON JSON Error: ${message}`, + source: 'fixson' + }); + } + + if (fixsonDiagnosticsListener) { + fixsonDiagnosticsListener({ + method: 'textDocument/publishDiagnostics', + params: { uri, diagnostics } + }); + } + } + + // Define window.api translation layer + window.api = { + highlightCode: (code, language) => { + if (window.hljs && typeof window.hljs.getLanguage === 'function' && window.hljs.getLanguage(language)) { + return window.hljs.highlight(code, { language }).value; + } + return null; + }, + + openFile: async () => { + if (window.showOpenFilePicker) { + try { + const [handle] = await window.showOpenFilePicker(); + const file = await handle.getFile(); + const content = await file.text(); + const filePath = `${activeWorkspacePath}/${file.name}`; + virtualWorkspace.files.set(filePath, { + name: file.name, + path: filePath, + content, + isDirectory: false + }); + return { path: filePath, content }; + } catch (err) { + if (err.name === 'AbortError') return null; + } + } + + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.onchange = async () => { + if (input.files && input.files[0]) { + const file = input.files[0]; + const content = await file.text(); + const filePath = `${activeWorkspacePath}/${file.name}`; + virtualWorkspace.files.set(filePath, { + name: file.name, + path: filePath, + content, + isDirectory: false + }); + resolve({ path: filePath, content }); + } else { + resolve(null); + } + }; + input.click(); + }); + }, + + readFile: async (filePath) => { + const entry = virtualWorkspace.files.get(filePath); + if (entry) { + return { path: entry.path, content: entry.content }; + } + return { path: filePath, content: '' }; + }, + + getWorkspacePath: async () => activeWorkspacePath, + + saveFile: async (data) => { + if (!data || typeof data.content !== 'string') return null; + let filePath = data.filePath; + if (!filePath) { + let counter = 1; + do { + filePath = `${activeWorkspacePath}/Untitled-${counter++}.txt`; + } while (virtualWorkspace.files.has(filePath)); + } + virtualWorkspace.files.set(filePath, { + name: filePath.split('/').pop(), + path: filePath, + content: data.content, + isDirectory: false + }); + try { + return await saveProjectAsZip() ? filePath : null; + } catch (error) { + console.error('Unable to export project:', error); + return null; + } + }, + + saveProjectZip: saveProjectAsZip, + + listDirectory: async (directoryPath) => { + const dir = directoryPath || activeWorkspacePath; + const results = []; + for (const [filePath, file] of virtualWorkspace.files.entries()) { + const parentDir = filePath.substring(0, filePath.lastIndexOf('/')); + if (parentDir === dir || dir === '/workspace') { + results.push({ + name: file.name, + path: file.path, + isDirectory: file.isDirectory + }); + } + } + return results; + }, + + openFolder: async () => { + if (window.showDirectoryPicker) { + try { + const dirHandle = await window.showDirectoryPicker(); + activeWorkspacePath = `/${dirHandle.name}`; + virtualWorkspace.files.clear(); + + for await (const entry of dirHandle.values()) { + if (entry.kind === 'file') { + const file = await entry.getFile(); + const content = await file.text(); + const filePath = `${activeWorkspacePath}/${file.name}`; + virtualWorkspace.files.set(filePath, { + name: file.name, + path: filePath, + content, + isDirectory: false + }); + } + } + return activeWorkspacePath; + } catch (err) { + if (err.name === 'AbortError') return null; + } + } + + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.webkitdirectory = true; + input.onchange = async () => { + if (input.files && input.files.length > 0) { + const folderName = input.files[0].webkitRelativePath?.split('/')[0] || 'folder'; + activeWorkspacePath = `/${folderName}`; + virtualWorkspace.files.clear(); + + for (const file of Array.from(input.files)) { + const content = await file.text(); + const filePath = `/${file.webkitRelativePath || `${folderName}/${file.name}`}`; + virtualWorkspace.files.set(filePath, { + name: file.name, + path: filePath, + content, + isDirectory: false + }); + } + resolve(activeWorkspacePath); + } else { + resolve(null); + } + }; + input.click(); + }); + }, + + setGitWorkspace: async () => {}, + gitBlame: async () => ({ ok: true, stdout: '', stderr: '' }), + + getSettings: async () => loadSettings(), + + setUserName: async (userName) => { + const settings = loadSettings(); + settings.userName = userName; + settings.namePromptCompleted = true; + return saveSettings(settings); + }, + + setTheme: async (theme) => { + const settings = loadSettings(); + settings.theme = theme; + return saveSettings(settings); + }, + + setLanguage: async (language) => { + const settings = loadSettings(); + settings.language = language; + return saveSettings(settings); + }, + + exit: () => { + window.location.href = '../index.html'; + }, + + // --- In-Browser JS FIXSON LSP Implementation --- + startFixson: async () => { + console.log('FIXSON JSON LSP started in browser'); + return true; + }, + sendFixsonMessage: (msg) => { + if (msg.method === 'textDocument/didOpen' || msg.method === 'textDocument/didChange') { + const uri = msg.params.textDocument.uri; + const text = msg.params.contentChanges + ? msg.params.contentChanges[0].text + : msg.params.textDocument.text; + validateJsonDocument(uri, text); + } else if (msg.method === 'textDocument/completion' && fixsonDiagnosticsListener) { + const items = ['name', 'version', 'description', 'author', 'license', 'homepage', 'repository', 'dependencies'].map((label) => ({ + label, + kind: 10 // Property + })); + fixsonDiagnosticsListener({ id: msg.id, result: { items } }); + } else if (msg.method === 'textDocument/hover' && fixsonDiagnosticsListener) { + fixsonDiagnosticsListener({ + id: msg.id, + result: { contents: { kind: 'plaintext', value: 'Fixson JSON Document (Web LSP)' } } + }); + } + }, + stopFixson: () => {}, + onFixsonMessage: (cb) => { fixsonDiagnosticsListener = cb; }, + onFixsonStderr: () => {}, + onFixsonError: () => {}, + onFixsonExit: () => {}, + + // Unused desktop LSPs + startClangd: async () => { throw new Error('clangd native binary unavailable in Web IDE'); }, + sendClangdMessage: () => {}, + stopClangd: () => {}, + onClangdMessage: () => {}, + onClangdStderr: () => {}, + onClangdError: () => {}, + + startPyright: async () => { throw new Error('pyright process unavailable in Web IDE'); }, + sendPyrightMessage: () => {}, + stopPyright: () => {}, + onPyrightMessage: () => {}, + onPyrightStderr: () => {}, + onPyrightError: () => {}, + onPyrightExit: () => {} + }; +})(); diff --git a/icons.js b/icons.js index fb8ab69..284ce89 100644 --- a/icons.js +++ b/icons.js @@ -2,7 +2,8 @@ let iconMap = {}; export async function initIcons() { try { - const response = await fetch('icons/style1/icons.json'); + const response = await fetch(new URL('./icons/style1/icons.json', import.meta.url)); + if (!response.ok) throw new Error(`Failed to load icons.json: HTTP ${response.status}`); iconMap = await response.json(); } catch (err) { console.error('Failed to load icons.json:', err); @@ -10,13 +11,14 @@ export async function initIcons() { } export function getFileIcon(fileName) { - if (!fileName) return iconMap.ui?.newFile || '▱'; - const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase(); + const fallback = iconMap.ui?.newFile || '▱'; + if (!fileName || !fileName.includes('.')) return fallback; + const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase(); + const aliases = { js: 'javascript', jsx: 'javascript', mjs: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', txt: 'plaintext', md: 'markdown' }; return ( - iconMap.extensions?.[ext] || - iconMap.languages?.[ext.replace('.', '')] || - iconMap.ui?.newFile || - '▱' + iconMap.extensions?.[`.${ext}`] || + iconMap.languages?.[aliases[ext] || ext] || + fallback ); } diff --git a/package.json b/package.json index 62bddd0..bfa43d1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "main.js", "scripts": { "start": "electron .", - "build": "npm --prefix packages/FIXSON-LSP install && npm --prefix packages/FIXSON-LSP run build", + "build": "npm --prefix packages/FIXSON-LSP install && npm --prefix packages/FIXSON-LSP run build && node scripts/build-webide.js", "dist:win": "npm run build && electron-builder --win nsis", "test": "npm run build && node --test", "lint": "eslint ." diff --git a/scripts/build-webide.js b/scripts/build-webide.js new file mode 100644 index 0000000..814cc95 --- /dev/null +++ b/scripts/build-webide.js @@ -0,0 +1,47 @@ +const fs = require('fs'); +const path = require('path'); + +const rootDir = path.join(__dirname, '..'); +const targetDir = path.join(rootDir, 'docs', 'webIDE', 'shared'); + +function copyRecursiveSync(src, dest) { + const exists = fs.existsSync(src); + const stats = exists && fs.statSync(src); + const isDirectory = stats && stats.isDirectory(); + + if (isDirectory) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + fs.readdirSync(src).forEach((childItemName) => { + copyRecursiveSync( + path.join(src, childItemName), + path.join(dest, childItemName) + ); + }); + } else if (exists) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + } +} + +function buildWebIdeShared() { + const itemsToSync = ['styles', 'UI', 'themes', 'icons', 'icons.js', 'themes.js']; + + itemsToSync.forEach((item) => { + const srcPath = path.join(rootDir, item); + const destPath = path.join(targetDir, item); + copyRecursiveSync(srcPath, destPath); + }); + + // The desktop MarkdownView imports node_modules; the web app has its own view. + fs.rmSync(path.join(targetDir, 'UI', 'markdown_view', 'view.js'), { force: true }); + + console.log('Successfully synced main IDE internals to docs/webIDE/shared/'); +} + +if (require.main === module) { + buildWebIdeShared(); +} + +module.exports = { buildWebIdeShared }; diff --git a/themes.js b/themes.js index ec54956..dde8f4e 100644 --- a/themes.js +++ b/themes.js @@ -3,7 +3,8 @@ const themeCache = new Map(); export async function loadTheme(themeId) { if (themeCache.has(themeId)) return themeCache.get(themeId); - const response = await fetch(`./themes/${themeId}.json`); + const response = await fetch(new URL(`./themes/${themeId}.json`, import.meta.url)); + if (!response.ok) throw new Error(`Failed to load theme ${themeId}: HTTP ${response.status}`); const theme = await response.json(); themeCache.set(themeId, theme); return theme;