GitHub improvements
This commit is contained in:
+981
-705
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,18 @@ class GitHubViewer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status line for a session.
|
||||
* @param {string} sessionID
|
||||
* @param {string} message
|
||||
*/
|
||||
setStatus(sessionID, message) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.setStatus(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an error event for a session.
|
||||
* @param {string} sessionID
|
||||
@@ -165,6 +177,16 @@ class GitHubViewerInstance {
|
||||
this._renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status line with a custom message.
|
||||
* @param {string} message
|
||||
*/
|
||||
setStatus(message) {
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.classList.remove('github-viewer-status-error');
|
||||
this._statusEl.textContent = message || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error message in the status bar.
|
||||
* @param {string} message
|
||||
@@ -272,6 +294,7 @@ class GitHubViewerInstance {
|
||||
nameEl.textContent = repo.name;
|
||||
topRow.appendChild(nameEl);
|
||||
|
||||
|
||||
if (repo.private) {
|
||||
const badge = createElement('span', 'github-repo-badge github-repo-private');
|
||||
badge.textContent = 'Private';
|
||||
@@ -288,8 +311,46 @@ class GitHubViewerInstance {
|
||||
topRow.appendChild(badge);
|
||||
}
|
||||
|
||||
const actions = createElement('div', 'github-repo-actions');
|
||||
|
||||
const downloadBtn = createElement('button', 'github-repo-download', {
|
||||
textContent: 'ZIP',
|
||||
title: 'Download repository as zip',
|
||||
});
|
||||
downloadBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._downloadRepo(repo);
|
||||
});
|
||||
actions.appendChild(downloadBtn);
|
||||
|
||||
const visitLink = createElement('button', 'github-repo-visit', {
|
||||
textContent: 'Visit',
|
||||
title: 'Open repository in browser',
|
||||
});
|
||||
visitLink.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (repo.htmlURL) {
|
||||
if (window.runtime && window.runtime.BrowserOpenURL) {
|
||||
window.runtime.BrowserOpenURL(repo.htmlURL);
|
||||
} else {
|
||||
window.open(repo.htmlURL, '_blank');
|
||||
}
|
||||
}
|
||||
});
|
||||
actions.appendChild(visitLink);
|
||||
|
||||
topRow.appendChild(actions);
|
||||
|
||||
card.appendChild(topRow);
|
||||
|
||||
if (repo.latestCommitter && repo.latestCommitAt) {
|
||||
const commitLine = createElement('div', 'github-repo-commit');
|
||||
const when = new Date(repo.latestCommitAt);
|
||||
const whenText = isNaN(when.getTime()) ? repo.latestCommitAt : when.toLocaleString();
|
||||
commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`;
|
||||
card.appendChild(commitLine);
|
||||
}
|
||||
|
||||
// Description
|
||||
if (repo.description) {
|
||||
const desc = createElement('div', 'github-repo-desc');
|
||||
@@ -324,6 +385,25 @@ class GitHubViewerInstance {
|
||||
return card;
|
||||
}
|
||||
|
||||
async _downloadRepo(repo) {
|
||||
if (!this._statusEl) return;
|
||||
const ref = repo.defaultBranch || '';
|
||||
this._statusEl.classList.remove('github-viewer-status-error');
|
||||
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
||||
|
||||
try {
|
||||
await window.go.api.Service.DownloadGitHubRepoArchive(
|
||||
this.sessionID,
|
||||
repo.fullName,
|
||||
ref,
|
||||
);
|
||||
this._updateStatus();
|
||||
} catch (err) {
|
||||
this._statusEl.textContent = `⚠ Download failed: ${err}`;
|
||||
this._statusEl.classList.add('github-viewer-status-error');
|
||||
}
|
||||
}
|
||||
|
||||
async _manualRefresh() {
|
||||
if (!this._statusEl) return;
|
||||
this._statusEl.textContent = 'Refreshing…';
|
||||
|
||||
@@ -183,12 +183,19 @@ class ProfileEditor {
|
||||
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test' });
|
||||
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
|
||||
const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' });
|
||||
const deleteBtn = !isNew
|
||||
? createElement('button', 'btn btn-danger', { textContent: 'Delete' })
|
||||
: null;
|
||||
|
||||
cancelBtn.addEventListener('click', () => this.close());
|
||||
testBtn.addEventListener('click', () => this._testConnection());
|
||||
saveBtn.addEventListener('click', () => this._handleSave(false));
|
||||
connectBtn.addEventListener('click', () => this._handleSave(true));
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => this._handleDelete());
|
||||
}
|
||||
|
||||
if (deleteBtn) footer.appendChild(deleteBtn);
|
||||
footer.appendChild(cancelBtn);
|
||||
footer.appendChild(testBtn);
|
||||
footer.appendChild(saveBtn);
|
||||
@@ -1464,6 +1471,22 @@ class ProfileEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async _handleDelete() {
|
||||
const ok = await this._confirmDelete();
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await window.go.api.Service.DeleteProfile(this._profile.id);
|
||||
this.app.state.removeProfile(this._profile.id);
|
||||
this.app.sidebar.render();
|
||||
this.app.notifications.success('Profile deleted');
|
||||
this.close();
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to delete profile: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async _testConnection() {
|
||||
this._collectFormValues();
|
||||
@@ -1485,6 +1508,51 @@ class ProfileEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a confirmation modal for profile deletion.
|
||||
* @returns {Promise<boolean>}
|
||||
* @private
|
||||
*/
|
||||
_confirmDelete() {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-sm');
|
||||
const label = escapeHtml(this._profile.displayName || this._profile.host || 'Untitled');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Delete Profile</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
|
||||
Delete <strong>${label}</strong>? This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-danger confirm-ok">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
});
|
||||
|
||||
const close = (val) => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
modal.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
resolve(val);
|
||||
};
|
||||
modal.querySelector('.modal-close').addEventListener('click', () => close(false));
|
||||
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false));
|
||||
modal.querySelector('.confirm-ok').addEventListener('click', () => close(true));
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,9 +42,9 @@ class Settings {
|
||||
/**
|
||||
* Opens the settings modal.
|
||||
*/
|
||||
async open() {
|
||||
async open(tabKey) {
|
||||
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
|
||||
this._activeTab = 'general';
|
||||
this._activeTab = tabKey || this._activeTab || 'general';
|
||||
|
||||
try {
|
||||
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
|
||||
@@ -55,6 +55,15 @@ class Settings {
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the settings modal on a specific tab.
|
||||
* @param {string} tabKey
|
||||
*/
|
||||
async openTab(tabKey) {
|
||||
this._activeTab = tabKey || 'general';
|
||||
return this.open(this._activeTab);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the settings modal.
|
||||
*/
|
||||
|
||||
+314
-2
@@ -19,11 +19,22 @@ class Sidebar {
|
||||
this.el = document.getElementById('sidebar-profiles');
|
||||
this.searchQuery = '';
|
||||
|
||||
/** @type {Set<string>} */
|
||||
this.selectedProfiles = new Set();
|
||||
|
||||
// Section collapse state
|
||||
this.collapsedSections = new Set();
|
||||
|
||||
// Dismiss context menus on any outside click
|
||||
document.addEventListener('click', () => dismissContextMenus());
|
||||
|
||||
if (this.el) {
|
||||
this.el.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.sidebar-item') && this.selectedProfiles.size > 0) {
|
||||
this._clearSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -41,6 +52,11 @@ class Sidebar {
|
||||
this.el.innerHTML = '';
|
||||
|
||||
const profiles = this._filteredProfiles();
|
||||
this._syncSelection(profiles);
|
||||
|
||||
if (this.selectedProfiles.size > 0) {
|
||||
this.el.appendChild(this._renderBulkActions());
|
||||
}
|
||||
|
||||
// Favourites
|
||||
const favourites = profiles.filter((p) => p.favourite);
|
||||
@@ -178,6 +194,10 @@ class Sidebar {
|
||||
dataset: { profileId: profile.id },
|
||||
});
|
||||
|
||||
if (this.selectedProfiles.has(profile.id)) {
|
||||
item.classList.add('sidebar-item-selected');
|
||||
}
|
||||
|
||||
// Highlight if this profile's session is active
|
||||
const activeSession = this.app.state.activeSessionId
|
||||
? this.app.state.getSession(this.app.state.activeSessionId)
|
||||
@@ -227,8 +247,21 @@ class Sidebar {
|
||||
item.appendChild(star);
|
||||
}
|
||||
|
||||
// Click → connect
|
||||
item.addEventListener('click', () => {
|
||||
// Prevent mousedown text selection in WKWebView
|
||||
item.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
|
||||
// Click → connect / multi-select
|
||||
item.addEventListener('click', (e) => {
|
||||
const multi = e.metaKey || e.ctrlKey;
|
||||
if (multi) {
|
||||
e.preventDefault();
|
||||
this._toggleSelection(profile.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedProfiles.size > 0) {
|
||||
this._clearSelection();
|
||||
}
|
||||
this.app.connectProfile(profile.id);
|
||||
});
|
||||
|
||||
@@ -406,6 +439,285 @@ class Sidebar {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Multi-select + bulk actions ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Removes selections that are no longer visible in the filtered list.
|
||||
* @param {Array<Object>} profiles
|
||||
* @private
|
||||
*/
|
||||
_syncSelection(profiles) {
|
||||
const valid = new Set(profiles.map((p) => p.id));
|
||||
for (const id of [...this.selectedProfiles]) {
|
||||
if (!valid.has(id)) this.selectedProfiles.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_toggleSelection(profileId) {
|
||||
if (this.selectedProfiles.has(profileId)) {
|
||||
this.selectedProfiles.delete(profileId);
|
||||
} else {
|
||||
this.selectedProfiles.add(profileId);
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_clearSelection() {
|
||||
if (this.selectedProfiles.size === 0) return;
|
||||
this.selectedProfiles.clear();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_getSelectedProfiles() {
|
||||
return this.app.state.profiles.filter((p) => this.selectedProfiles.has(p.id));
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_renderBulkActions() {
|
||||
const bar = createElement('div', 'sidebar-bulk-actions');
|
||||
const count = createElement('span', 'sidebar-bulk-count', {
|
||||
textContent: `${this.selectedProfiles.size} selected`,
|
||||
});
|
||||
bar.appendChild(count);
|
||||
|
||||
const actions = createElement('div', 'sidebar-bulk-buttons');
|
||||
const makeBtn = (label, handler, className) => {
|
||||
const btn = createElement('button', `btn btn-xs ${className || ''}`.trim(), {
|
||||
textContent: label,
|
||||
});
|
||||
btn.addEventListener('click', handler);
|
||||
actions.appendChild(btn);
|
||||
};
|
||||
|
||||
makeBtn('Connect', () => this._bulkConnect());
|
||||
makeBtn('Disconnect', () => this._bulkDisconnect(), 'btn-outline');
|
||||
makeBtn('Tags', () => this._bulkEditTags(), 'btn-outline');
|
||||
makeBtn('Move Group', () => this._bulkMoveGroup(), 'btn-outline');
|
||||
makeBtn('Clear', () => this._clearSelection(), 'btn-ghost');
|
||||
|
||||
bar.appendChild(actions);
|
||||
return bar;
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_bulkConnect() {
|
||||
const profiles = this._getSelectedProfiles();
|
||||
for (const p of profiles) {
|
||||
this.app.connectProfile(p.id);
|
||||
}
|
||||
this._clearSelection();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async _bulkDisconnect() {
|
||||
const sessions = this.app.state.getAllSessions()
|
||||
.filter((s) => this.selectedProfiles.has(s.profileId));
|
||||
if (sessions.length === 0) {
|
||||
this.app.notifications.info('No active sessions to disconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await this._confirmBulkDisconnect(sessions.length);
|
||||
if (!ok) return;
|
||||
|
||||
for (const s of sessions) {
|
||||
await this.app.closeSessionImmediate(s.id);
|
||||
}
|
||||
this._clearSelection();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async _bulkEditTags() {
|
||||
const result = await this._promptTagEdit();
|
||||
if (!result) return;
|
||||
|
||||
const profiles = this._getSelectedProfiles();
|
||||
for (const p of profiles) {
|
||||
const updated = JSON.parse(JSON.stringify(p));
|
||||
const existing = updated.tags || [];
|
||||
if (result.mode === 'replace') {
|
||||
updated.tags = result.tags;
|
||||
} else {
|
||||
const merged = new Set([...existing, ...result.tags]);
|
||||
updated.tags = [...merged];
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await window.go.api.Service.UpdateProfile(updated);
|
||||
this.app.state.updateProfile(saved);
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to update tags: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
this.render();
|
||||
this._clearSelection();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
async _bulkMoveGroup() {
|
||||
const folder = await this._promptGroupMove();
|
||||
if (folder === null) return;
|
||||
|
||||
const profiles = this._getSelectedProfiles();
|
||||
for (const p of profiles) {
|
||||
const updated = JSON.parse(JSON.stringify(p));
|
||||
updated.folder = folder;
|
||||
try {
|
||||
const saved = await window.go.api.Service.UpdateProfile(updated);
|
||||
this.app.state.updateProfile(saved);
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to move group: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
this.render();
|
||||
this._clearSelection();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_confirmBulkDisconnect(count) {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-sm');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Disconnect Sessions</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
|
||||
Disconnect ${count} active ${count === 1 ? 'session' : 'sessions'}?
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-danger confirm-ok">Disconnect</button>
|
||||
</div>
|
||||
`;
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
});
|
||||
|
||||
const close = (val) => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
modal.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
resolve(val);
|
||||
};
|
||||
modal.querySelector('.modal-close').addEventListener('click', () => close(false));
|
||||
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false));
|
||||
modal.querySelector('.confirm-ok').addEventListener('click', () => close(true));
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); });
|
||||
});
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_promptTagEdit() {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-sm');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Edit Tags</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tags (comma separated)</label>
|
||||
<input class="form-input tag-input" type="text" placeholder="prod, linux, db" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-outline confirm-add">Add Tags</button>
|
||||
<button class="btn btn-sm btn-primary confirm-replace">Replace Tags</button>
|
||||
</div>
|
||||
`;
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
});
|
||||
|
||||
const input = modal.querySelector('.tag-input');
|
||||
const parseTags = () => {
|
||||
const raw = (input.value || '').split(',');
|
||||
return raw.map((t) => t.trim()).filter((t) => t.length > 0);
|
||||
};
|
||||
|
||||
const close = (val) => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
modal.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
resolve(val);
|
||||
};
|
||||
modal.querySelector('.modal-close').addEventListener('click', () => close(null));
|
||||
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
|
||||
modal.querySelector('.confirm-add').addEventListener('click', () => {
|
||||
close({ mode: 'add', tags: parseTags() });
|
||||
});
|
||||
modal.querySelector('.confirm-replace').addEventListener('click', () => {
|
||||
close({ mode: 'replace', tags: parseTags() });
|
||||
});
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
|
||||
setTimeout(() => input.focus(), 50);
|
||||
});
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_promptGroupMove() {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-sm');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Move to Group</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Group name</label>
|
||||
<input class="form-input group-input" type="text" placeholder="e.g. Production" />
|
||||
<p style="font-size:var(--text-xs);color:var(--text-secondary);margin-top:6px;">
|
||||
Leave blank to remove the group.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary confirm-ok">Move</button>
|
||||
</div>
|
||||
`;
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
});
|
||||
|
||||
const input = modal.querySelector('.group-input');
|
||||
const close = (val) => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
modal.classList.remove('is-visible');
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
resolve(val);
|
||||
};
|
||||
modal.querySelector('.modal-close').addEventListener('click', () => close(null));
|
||||
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
|
||||
modal.querySelector('.confirm-ok').addEventListener('click', () => close(input.value.trim()));
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
|
||||
setTimeout(() => input.focus(), 50);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filtering ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+356
-321
@@ -1,24 +1,24 @@
|
||||
(function() {
|
||||
/**
|
||||
* Nexus Terminal — xterm.js terminal manager.
|
||||
* Creates, manages, and destroys xterm.js instances for SSH and Telnet sessions.
|
||||
* Handles data routing, resizing, and search.
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — xterm.js terminal manager.
|
||||
* Creates, manages, and destroys xterm.js instances for SSH and Telnet sessions.
|
||||
* Handles data routing, resizing, and search.
|
||||
*/
|
||||
|
||||
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */
|
||||
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */
|
||||
|
||||
class TerminalManager {
|
||||
class TerminalManager {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.app = app;
|
||||
|
||||
/**
|
||||
* Map of sessionId → { term, fitAddon, searchAddon, observer }.
|
||||
* @type {Map<string, Object>}
|
||||
*/
|
||||
this.terminals = new Map();
|
||||
/**
|
||||
* Map of sessionId → { term, fitAddon, searchAddon, observer }.
|
||||
* @type {Map<string, Object>}
|
||||
*/
|
||||
this.terminals = new Map();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -33,171 +33,190 @@ class TerminalManager {
|
||||
* @returns {Terminal} The xterm.js Terminal instance.
|
||||
*/
|
||||
createTerminal(sessionId, containerId, profile) {
|
||||
const themeName = (this.app.state.preferences || {}).terminalTheme || 'charcoal';
|
||||
const term = new Terminal({
|
||||
theme: window.NexusTerminal.getTerminalTheme(themeName),
|
||||
fontFamily: profile.terminalFontFamily || 'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
|
||||
fontSize: profile.terminalFontSize || 14,
|
||||
scrollback: profile.terminalScrollback || 10000,
|
||||
cursorStyle: profile.terminalCursorStyle || 'block',
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
windowsMode: false,
|
||||
const themeName =
|
||||
(this.app.state.preferences || {}).terminalTheme || "charcoal";
|
||||
const term = new Terminal({
|
||||
theme: window.NexusTerminal.getTerminalTheme(themeName),
|
||||
fontFamily:
|
||||
profile.terminalFontFamily ||
|
||||
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
|
||||
fontSize: profile.terminalFontSize || 14,
|
||||
scrollback: profile.terminalScrollback || 10000,
|
||||
cursorStyle: profile.terminalCursorStyle || "block",
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
windowsMode: false,
|
||||
});
|
||||
|
||||
// Load addons
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const searchAddon = new SearchAddon.SearchAddon();
|
||||
let webLinksAddon = null;
|
||||
let unicode11Addon = null;
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(searchAddon);
|
||||
|
||||
try {
|
||||
unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
term.loadAddon(unicode11Addon);
|
||||
term.unicode.activeVersion = "11";
|
||||
} catch (_) {
|
||||
// Unicode 11 addon is optional; fall back to default unicode handling.
|
||||
}
|
||||
|
||||
// WebLinksAddon may not always be available
|
||||
try {
|
||||
webLinksAddon = new WebLinksAddon.WebLinksAddon((_event, uri) => {
|
||||
window.runtime.BrowserOpenURL(uri);
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
} catch (_) {
|
||||
// Not critical — links just won't be clickable
|
||||
}
|
||||
|
||||
// Load addons
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const searchAddon = new SearchAddon.SearchAddon();
|
||||
let webLinksAddon = null;
|
||||
let unicode11Addon = null;
|
||||
// Open the terminal in the DOM container
|
||||
const container = document.getElementById(containerId);
|
||||
term.open(container);
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(searchAddon);
|
||||
let suppressPasteEvent = false;
|
||||
const attachPasteGuard = () => {
|
||||
const inputEl = term.textarea || container.querySelector("textarea");
|
||||
if (!inputEl) return;
|
||||
inputEl.addEventListener("paste", (e) => {
|
||||
if (!suppressPasteEvent) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressPasteEvent = false;
|
||||
});
|
||||
};
|
||||
attachPasteGuard();
|
||||
|
||||
// Right-click context menu on the terminal viewport
|
||||
const termEl = container.querySelector(".xterm");
|
||||
if (termEl) {
|
||||
termEl.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
this._showTerminalContextMenu(sessionId, term, e.clientX, e.clientY);
|
||||
});
|
||||
}
|
||||
|
||||
// Force a synchronous layout reflow so the container reports its
|
||||
// true dimensions, then fit the terminal to fill the space.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
container.offsetHeight;
|
||||
const doFit = () => {
|
||||
try {
|
||||
unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
term.loadAddon(unicode11Addon);
|
||||
term.unicode.activeVersion = '11';
|
||||
fitAddon.fit();
|
||||
} catch (_) {
|
||||
// Unicode 11 addon is optional; fall back to default unicode handling.
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
doFit();
|
||||
// Belt-and-braces: retry once after a paint in case the initial
|
||||
// reflow dimensions were still stale.
|
||||
requestAnimationFrame(doFit);
|
||||
|
||||
// WebLinksAddon may not always be available
|
||||
// Forward user keystrokes to the backend
|
||||
term.onData((data) => {
|
||||
try {
|
||||
webLinksAddon = new WebLinksAddon.WebLinksAddon((_event, uri) => {
|
||||
window.runtime.BrowserOpenURL(uri);
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
const b64 = btoa(data);
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
|
||||
// Broadcast to all other connected sessions if enabled
|
||||
if (this.app.state.broadcastMode) {
|
||||
for (const [otherId] of this.terminals) {
|
||||
if (otherId === sessionId) continue;
|
||||
const otherSession = this.app.state.getSession(otherId);
|
||||
if (otherSession && otherSession.state === "connected") {
|
||||
try {
|
||||
window.go.api.Service.WriteSession(otherId, b64);
|
||||
} catch (_) {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("WriteSession error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Clipboard: copy selection on Cmd/Ctrl+C, paste on Cmd/Ctrl+V
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Cmd/Ctrl+C — copy selected text (let xterm handle it normally if no selection)
|
||||
if (mod && e.key === "c" && e.type === "keydown") {
|
||||
const selection = term.getSelection();
|
||||
if (selection) {
|
||||
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||
window.runtime.ClipboardSetText(selection);
|
||||
} else {
|
||||
navigator.clipboard.writeText(selection).catch(function () {});
|
||||
}
|
||||
return false; // Prevent xterm from sending Ctrl+C
|
||||
}
|
||||
// No selection — let Ctrl+C pass through as SIGINT
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+V — paste from clipboard
|
||||
if (mod && e.key === "v" && e.type === "keydown") {
|
||||
suppressPasteEvent = true;
|
||||
setTimeout(() => {
|
||||
suppressPasteEvent = false;
|
||||
}, 250);
|
||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||
window.runtime
|
||||
.ClipboardGetText()
|
||||
.then(function (text) {
|
||||
if (text) term.paste(text);
|
||||
})
|
||||
.catch(function () {});
|
||||
} else {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then(function (text) {
|
||||
if (text) term.paste(text);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Notify backend when terminal dimensions change
|
||||
term.onResize(({ cols, rows }) => {
|
||||
try {
|
||||
window.go.api.Service.ResizeSession(sessionId, cols, rows);
|
||||
} catch (err) {
|
||||
console.error("ResizeSession error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fit when the container itself resizes
|
||||
const observer = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (_) {
|
||||
// Not critical — links just won't be clickable
|
||||
// Ignore transient errors during layout shifts
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
// Open the terminal in the DOM container
|
||||
const container = document.getElementById(containerId);
|
||||
term.open(container);
|
||||
this.terminals.set(sessionId, {
|
||||
term,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
webLinksAddon,
|
||||
observer,
|
||||
});
|
||||
|
||||
let suppressPasteEvent = false;
|
||||
const attachPasteGuard = () => {
|
||||
const inputEl = term.textarea || container.querySelector('textarea');
|
||||
if (!inputEl) return;
|
||||
inputEl.addEventListener('paste', (e) => {
|
||||
if (!suppressPasteEvent) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressPasteEvent = false;
|
||||
});
|
||||
};
|
||||
attachPasteGuard();
|
||||
|
||||
// Right-click context menu on the terminal viewport
|
||||
const termEl = container.querySelector('.xterm');
|
||||
if (termEl) {
|
||||
termEl.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
this._showTerminalContextMenu(sessionId, term, e.clientX, e.clientY);
|
||||
});
|
||||
}
|
||||
|
||||
// Force a synchronous layout reflow so the container reports its
|
||||
// true dimensions, then fit the terminal to fill the space.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
container.offsetHeight;
|
||||
const doFit = () => { try { fitAddon.fit(); } catch (_) { /* noop */ } };
|
||||
doFit();
|
||||
// Belt-and-braces: retry once after a paint in case the initial
|
||||
// reflow dimensions were still stale.
|
||||
requestAnimationFrame(doFit);
|
||||
|
||||
// Forward user keystrokes to the backend
|
||||
term.onData((data) => {
|
||||
try {
|
||||
const b64 = btoa(data);
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
|
||||
// Broadcast to all other connected sessions if enabled
|
||||
if (this.app.state.broadcastMode) {
|
||||
for (const [otherId] of this.terminals) {
|
||||
if (otherId === sessionId) continue;
|
||||
const otherSession = this.app.state.getSession(otherId);
|
||||
if (otherSession && otherSession.state === 'connected') {
|
||||
try {
|
||||
window.go.api.Service.WriteSession(otherId, b64);
|
||||
} catch (_) { /* non-critical */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WriteSession error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Clipboard: copy selection on Cmd/Ctrl+C, paste on Cmd/Ctrl+V
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Cmd/Ctrl+C — copy selected text (let xterm handle it normally if no selection)
|
||||
if (mod && e.key === 'c' && e.type === 'keydown') {
|
||||
const selection = term.getSelection();
|
||||
if (selection) {
|
||||
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||
window.runtime.ClipboardSetText(selection);
|
||||
} else {
|
||||
navigator.clipboard.writeText(selection).catch(function() {});
|
||||
}
|
||||
return false; // Prevent xterm from sending Ctrl+C
|
||||
}
|
||||
// No selection — let Ctrl+C pass through as SIGINT
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+V — paste from clipboard
|
||||
if (mod && e.key === 'v' && e.type === 'keydown') {
|
||||
suppressPasteEvent = true;
|
||||
setTimeout(() => { suppressPasteEvent = false; }, 250);
|
||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||
window.runtime.ClipboardGetText().then(function(text) {
|
||||
if (text) term.paste(text);
|
||||
}).catch(function() {});
|
||||
} else {
|
||||
navigator.clipboard.readText().then(function(text) {
|
||||
if (text) term.paste(text);
|
||||
}).catch(function() {});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Notify backend when terminal dimensions change
|
||||
term.onResize(({ cols, rows }) => {
|
||||
try {
|
||||
window.go.api.Service.ResizeSession(sessionId, cols, rows);
|
||||
} catch (err) {
|
||||
console.error('ResizeSession error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fit when the container itself resizes
|
||||
const observer = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (_) {
|
||||
// Ignore transient errors during layout shifts
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
this.terminals.set(sessionId, {
|
||||
term,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
webLinksAddon,
|
||||
observer,
|
||||
});
|
||||
|
||||
return term;
|
||||
return term;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,15 +225,15 @@ class TerminalManager {
|
||||
* @param {string} base64Data
|
||||
*/
|
||||
handleData(sessionId, base64Data) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry) return;
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry) return;
|
||||
|
||||
try {
|
||||
const raw = atob(base64Data);
|
||||
entry.term.write(raw);
|
||||
} catch (err) {
|
||||
console.error('Failed to decode session data:', err);
|
||||
}
|
||||
try {
|
||||
const raw = atob(base64Data);
|
||||
entry.term.write(raw);
|
||||
} catch (err) {
|
||||
console.error("Failed to decode session data:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,12 +241,12 @@ class TerminalManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
destroyTerminal(sessionId) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry) return;
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.observer.disconnect();
|
||||
entry.term.dispose();
|
||||
this.terminals.delete(sessionId);
|
||||
entry.observer.disconnect();
|
||||
entry.term.dispose();
|
||||
this.terminals.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,28 +254,32 @@ class TerminalManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
showTerminal(sessionId) {
|
||||
for (const [id, entry] of this.terminals) {
|
||||
const panel = document.getElementById(`panel-${id}`);
|
||||
if (panel) {
|
||||
panel.style.display = id === sessionId ? 'flex' : 'none';
|
||||
}
|
||||
for (const [id, entry] of this.terminals) {
|
||||
const panel = document.getElementById(`panel-${id}`);
|
||||
if (panel) {
|
||||
panel.style.display = id === sessionId ? "flex" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fit the newly visible terminal
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) {
|
||||
requestAnimationFrame(() => {
|
||||
try { entry.fitAddon.fit(); } catch (_) { /* noop */ }
|
||||
});
|
||||
}
|
||||
// Re-fit the newly visible terminal
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fitAddon.fit();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fits the currently active terminal to its container.
|
||||
*/
|
||||
fitActive() {
|
||||
const id = this.app.state.activeSessionId;
|
||||
if (id) this.fitSession(id);
|
||||
const id = this.app.state.activeSessionId;
|
||||
if (id) this.fitSession(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,12 +287,16 @@ class TerminalManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
fitSession(sessionId) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) {
|
||||
requestAnimationFrame(() => {
|
||||
try { entry.fitAddon.fit(); } catch (_) { /* noop */ }
|
||||
});
|
||||
}
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
entry.fitAddon.fit();
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,25 +310,25 @@ class TerminalManager {
|
||||
* @returns {boolean} Whether a match was found.
|
||||
*/
|
||||
search(sessionId, query, options = {}) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry) return false;
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry) return false;
|
||||
|
||||
if (!query) {
|
||||
entry.searchAddon.clearDecorations();
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
entry.searchAddon.clearDecorations();
|
||||
return false;
|
||||
}
|
||||
|
||||
return entry.searchAddon.findNext(query, {
|
||||
regex: options.regex || false,
|
||||
caseSensitive: options.caseSensitive || false,
|
||||
wholeWord: options.wholeWord || false,
|
||||
decorations: {
|
||||
matchBackground: '#ffa72633',
|
||||
activeMatchBackground: '#4ecdc466',
|
||||
matchBorder: '#ffa726',
|
||||
activeMatchBorder: '#4ecdc4',
|
||||
},
|
||||
});
|
||||
return entry.searchAddon.findNext(query, {
|
||||
regex: options.regex || false,
|
||||
caseSensitive: options.caseSensitive || false,
|
||||
wholeWord: options.wholeWord || false,
|
||||
decorations: {
|
||||
matchBackground: "#ffa72633",
|
||||
activeMatchBackground: "#4ecdc466",
|
||||
matchBorder: "#ffa726",
|
||||
activeMatchBorder: "#4ecdc4",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,13 +339,13 @@ class TerminalManager {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
searchPrevious(sessionId, query, options = {}) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry || !query) return false;
|
||||
return entry.searchAddon.findPrevious(query, {
|
||||
regex: options.regex || false,
|
||||
caseSensitive: options.caseSensitive || false,
|
||||
wholeWord: options.wholeWord || false,
|
||||
});
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry || !query) return false;
|
||||
return entry.searchAddon.findPrevious(query, {
|
||||
regex: options.regex || false,
|
||||
caseSensitive: options.caseSensitive || false,
|
||||
wholeWord: options.wholeWord || false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,8 +353,8 @@ class TerminalManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
clearTerminal(sessionId) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) entry.term.clear();
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) entry.term.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,8 +362,8 @@ class TerminalManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
resetTerminal(sessionId) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) entry.term.reset();
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) entry.term.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,8 +371,8 @@ class TerminalManager {
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
focusTerminal(sessionId) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) entry.term.focus();
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry) entry.term.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,10 +380,10 @@ class TerminalManager {
|
||||
* @param {string} themeName
|
||||
*/
|
||||
applyTheme(themeName) {
|
||||
const themeObj = window.NexusTerminal.getTerminalTheme(themeName);
|
||||
for (const [, entry] of this.terminals) {
|
||||
entry.term.options.theme = themeObj;
|
||||
}
|
||||
const themeObj = window.NexusTerminal.getTerminalTheme(themeName);
|
||||
for (const [, entry] of this.terminals) {
|
||||
entry.term.options.theme = themeObj;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────
|
||||
@@ -370,95 +397,103 @@ class TerminalManager {
|
||||
* @private
|
||||
*/
|
||||
_showTerminalContextMenu(sessionId, term, x, y) {
|
||||
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
||||
dismissContextMenus();
|
||||
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
||||
dismissContextMenus();
|
||||
|
||||
const hasSelection = term.hasSelection();
|
||||
const menu = createElement('div', 'context-menu');
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
const hasSelection = term.hasSelection();
|
||||
const menu = createElement("div", "context-menu");
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: 'Copy',
|
||||
disabled: !hasSelection,
|
||||
action: () => {
|
||||
const text = term.getSelection();
|
||||
if (text) {
|
||||
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||
window.runtime.ClipboardSetText(text);
|
||||
} else {
|
||||
navigator.clipboard.writeText(text).catch(function() {});
|
||||
}
|
||||
this.app.notifications.info('Copied to clipboard');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Paste',
|
||||
action: () => {
|
||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||
window.runtime.ClipboardGetText().then((text) => {
|
||||
if (text) term.paste(text);
|
||||
}).catch(function() {});
|
||||
} else {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) term.paste(text);
|
||||
}).catch(function() {});
|
||||
}
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: 'Select All',
|
||||
action: () => term.selectAll(),
|
||||
},
|
||||
{
|
||||
label: 'Find…',
|
||||
action: () => this.app._openTerminalSearch(),
|
||||
},
|
||||
{
|
||||
label: 'Send Snippet…',
|
||||
action: () => this.app.snippets.open(),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: 'Clear Terminal',
|
||||
action: () => term.clear(),
|
||||
},
|
||||
{
|
||||
label: 'Reset Terminal',
|
||||
action: () => term.reset(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of items) {
|
||||
if (entry.divider) {
|
||||
menu.appendChild(createElement('div', 'context-menu-divider'));
|
||||
continue;
|
||||
const items = [
|
||||
{
|
||||
label: "Copy",
|
||||
disabled: !hasSelection,
|
||||
action: () => {
|
||||
const text = term.getSelection();
|
||||
if (text) {
|
||||
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||
window.runtime.ClipboardSetText(text);
|
||||
} else {
|
||||
navigator.clipboard.writeText(text).catch(function () {});
|
||||
}
|
||||
this.app.notifications.info("Copied to clipboard");
|
||||
}
|
||||
const btn = createElement('button', 'context-menu-item', {
|
||||
textContent: entry.label,
|
||||
});
|
||||
if (entry.disabled) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('context-menu-disabled');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Paste",
|
||||
action: () => {
|
||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||
window.runtime
|
||||
.ClipboardGetText()
|
||||
.then((text) => {
|
||||
if (text) term.paste(text);
|
||||
})
|
||||
.catch(function () {});
|
||||
} else {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (text) term.paste(text);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
dismissContextMenus();
|
||||
entry.action();
|
||||
});
|
||||
// Prevent mousedown from causing text deselection in WKWebView
|
||||
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
menu.appendChild(btn);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Select All",
|
||||
action: () => term.selectAll(),
|
||||
},
|
||||
{
|
||||
label: "Find…",
|
||||
action: () => this.app._openTerminalSearch(),
|
||||
},
|
||||
{
|
||||
label: "Send Snippet…",
|
||||
action: () => this.app.snippets.open(),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Clear Terminal",
|
||||
action: () => term.clear(),
|
||||
},
|
||||
{
|
||||
label: "Reset Terminal",
|
||||
action: () => term.reset(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const entry of items) {
|
||||
if (entry.divider) {
|
||||
menu.appendChild(createElement("div", "context-menu-divider"));
|
||||
continue;
|
||||
}
|
||||
const btn = createElement("button", "context-menu-item", {
|
||||
textContent: entry.label,
|
||||
});
|
||||
if (entry.disabled) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("context-menu-disabled");
|
||||
}
|
||||
btn.addEventListener("click", () => {
|
||||
dismissContextMenus();
|
||||
entry.action();
|
||||
});
|
||||
// Prevent mousedown from causing text deselection in WKWebView
|
||||
btn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Keep within viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 8}px`;
|
||||
if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 8}px`;
|
||||
// Keep within viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth)
|
||||
menu.style.left = `${window.innerWidth - rect.width - 8}px`;
|
||||
if (rect.bottom > window.innerHeight)
|
||||
menu.style.top = `${window.innerHeight - rect.height - 8}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -467,12 +502,12 @@ class TerminalManager {
|
||||
* @private
|
||||
*/
|
||||
_buildTheme() {
|
||||
const themeName = (this.app.state.preferences || {}).terminalTheme || 'charcoal';
|
||||
return window.NexusTerminal.getTerminalTheme(themeName);
|
||||
const themeName =
|
||||
(this.app.state.preferences || {}).terminalTheme || "charcoal";
|
||||
return window.NexusTerminal.getTerminalTheme(themeName);
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.TerminalManager = TerminalManager;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.TerminalManager = TerminalManager;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user