diff --git a/app_test.go b/app_test.go
index 788df1e..df10e86 100644
--- a/app_test.go
+++ b/app_test.go
@@ -98,7 +98,7 @@ func TestParseCSVString(t *testing.T) {
func TestGetCommands(t *testing.T) {
reg := NewCommandRegistry()
cmds := reg.GetCommands()
- if len(cmds) != 14 {
+ if len(cmds) != 15 {
t.Errorf("expected 12 commands, got %d", len(cmds))
}
// Check that all have IDs
diff --git a/commands.go b/commands.go
index 467a54e..8c62e2a 100644
--- a/commands.go
+++ b/commands.go
@@ -32,5 +32,6 @@ func (c *CommandRegistry) GetCommands() []Command {
{ID: "open-right", Name: "Insert Column Right", Shortcut: ""},
{ID: "sort-asc", Name: "Sort A-Z", Shortcut: ""},
{ID: "sort-desc", Name: "Sort Z-A", Shortcut: ""},
+ {ID: "sort-advanced", Name: "Sort Advanced", Shortcut: ""},
}
}
diff --git a/frontend/index.html b/frontend/index.html
index c4d8580..ad7ed40 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -25,6 +25,20 @@
+
+
Sort Advanced
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
index e9a1e02..6213a51 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -26,6 +26,11 @@ const commandInput = $('#command-input');
const commandList = $('#command-list');
const overlay = $('#overlay');
const tableContainer = $('#table-container');
+const sortModal = $('#sort-modal');
+const sortInput = $('#sort-columns-input');
+const sortAutocomplete = $('#sort-autocomplete');
+const sortConfirmBtn = $('#sort-confirm-btn');
+const sortCancelBtn = $('#sort-cancel-btn');
// Default column width
const DEFAULT_COL_WIDTH = 120;
@@ -609,6 +614,7 @@ async function loadCommands() {
{ ID: 'open-right', Name: 'Insert Column Right', Shortcut: '' },
{ ID: 'sort-asc', Name: 'Sort A-Z', Shortcut: '' },
{ ID: 'sort-desc', Name: 'Sort Z-A', Shortcut: '' },
+ { ID: 'sort-advanced', Name: 'Sort Advanced', Shortcut: '' },
];
}
}
@@ -691,7 +697,10 @@ commandInput.addEventListener('keydown', (e) => {
e.stopPropagation();
});
-overlay.addEventListener('click', closeCommandPalette);
+overlay.addEventListener('click', () => {
+ if (!commandPalette.classList.contains('hidden')) closeCommandPalette();
+ if (!sortModal.classList.contains('hidden')) closeSortAdvanced();
+});
// ===== Command execution =====
async function executeCommand(id) {
@@ -710,6 +719,7 @@ async function executeCommand(id) {
case 'open-right': doInsertColRight(); break;
case 'sort-asc': doSort(true); break;
case 'sort-desc': doSort(false); break;
+ case 'sort-advanced': openSortAdvanced(); break;
}
}
@@ -936,6 +946,194 @@ function doSort(ascending) {
setStatus(`Sorted by ${state.headers[col] || colLabel(col)} ${ascending ? 'A-Z' : 'Z-A'}`);
}
+// ===== Sort Advanced Modal =====
+let acIndex = -1;
+
+function openSortAdvanced() {
+ sortModal.classList.remove('hidden');
+ overlay.classList.remove('hidden');
+ sortInput.value = '';
+ hideSortAutocomplete();
+ sortInput.focus();
+}
+
+function closeSortAdvanced() {
+ sortModal.classList.add('hidden');
+ overlay.classList.add('hidden');
+ sortInput.value = '';
+ hideSortAutocomplete();
+}
+
+function hideSortAutocomplete() {
+ sortAutocomplete.classList.add('hidden');
+ sortAutocomplete.innerHTML = '';
+ acIndex = -1;
+}
+
+function getCurrentToken() {
+ const val = sortInput.value;
+ const cursor = sortInput.selectionStart;
+ const before = val.substring(0, cursor);
+ const lastComma = before.lastIndexOf(',');
+ return {
+ text: before.substring(lastComma + 1).trim(),
+ start: lastComma + 1,
+ cursorPos: cursor,
+ };
+}
+
+function getAlreadyChosen() {
+ const parts = sortInput.value.split(',');
+ const cursor = sortInput.selectionStart;
+ const before = sortInput.value.substring(0, cursor);
+ const tokenIdx = before.split(',').length - 1;
+ return parts
+ .map(p => p.trim().toLowerCase())
+ .filter((_, i) => i !== tokenIdx)
+ .filter(p => p.length > 0);
+}
+
+function updateSortAutocomplete() {
+ const { text } = getCurrentToken();
+ const lower = text.toLowerCase();
+ const chosen = getAlreadyChosen();
+
+ const matches = state.headers
+ .filter(h => h.toLowerCase().includes(lower))
+ .filter(h => !chosen.includes(h.toLowerCase()))
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
+
+ if (matches.length === 0 || (matches.length === 1 && matches[0].toLowerCase() === lower)) {
+ hideSortAutocomplete();
+ return;
+ }
+
+ sortAutocomplete.innerHTML = '';
+ sortAutocomplete.classList.remove('hidden');
+ acIndex = -1;
+
+ matches.forEach((name, i) => {
+ const div = document.createElement('div');
+ div.className = 'ac-item';
+ div.textContent = name;
+ div.addEventListener('mousedown', (e) => {
+ e.preventDefault(); // keep focus on input
+ applySortCompletion(name);
+ });
+ sortAutocomplete.appendChild(div);
+ });
+}
+
+function applySortCompletion(name) {
+ const val = sortInput.value;
+ const cursor = sortInput.selectionStart;
+ const before = val.substring(0, cursor);
+ const after = val.substring(cursor);
+ const lastComma = before.lastIndexOf(',');
+ const prefix = before.substring(0, lastComma + 1);
+ const spacing = lastComma >= 0 ? ' ' : '';
+ const newVal = prefix + spacing + name + after;
+ sortInput.value = newVal;
+ const newCursor = (prefix + spacing + name).length;
+ sortInput.setSelectionRange(newCursor, newCursor);
+ hideSortAutocomplete();
+ sortInput.focus();
+}
+
+function confirmSortAdvanced() {
+ const names = sortInput.value
+ .split(',')
+ .map(s => s.trim())
+ .filter(s => s.length > 0);
+
+ const colIndices = [];
+ for (const name of names) {
+ const idx = state.headers.findIndex(
+ h => h.toLowerCase() === name.toLowerCase()
+ );
+ if (idx === -1) {
+ setStatus(`Unknown column: "${name}"`);
+ return;
+ }
+ colIndices.push(idx);
+ }
+
+ if (colIndices.length === 0) {
+ setStatus('No columns specified');
+ return;
+ }
+
+ state.rows.sort((a, b) => {
+ for (const col of colIndices) {
+ const va = (a[col] || '').toLowerCase();
+ const vb = (b[col] || '').toLowerCase();
+ const cmp = va.localeCompare(vb, undefined, { numeric: true });
+ if (cmp !== 0) return cmp;
+ }
+ return 0;
+ });
+
+ closeSortAdvanced();
+ render();
+ setStatus('Sorted by ' + names.join(', '));
+}
+
+sortInput.addEventListener('input', updateSortAutocomplete);
+sortInput.addEventListener('focus', updateSortAutocomplete);
+sortInput.addEventListener('blur', () => {
+ // Small delay to allow mousedown on ac-item to fire
+ setTimeout(hideSortAutocomplete, 150);
+});
+
+sortInput.addEventListener('keydown', (e) => {
+ const items = sortAutocomplete.querySelectorAll('.ac-item');
+
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!sortAutocomplete.classList.contains('hidden')) {
+ hideSortAutocomplete();
+ } else {
+ closeSortAdvanced();
+ }
+ return;
+ }
+
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ if (acIndex >= 0 && items[acIndex]) {
+ applySortCompletion(items[acIndex].textContent);
+ } else {
+ confirmSortAdvanced();
+ }
+ return;
+ }
+
+ if (e.key === 'ArrowDown' && items.length > 0) {
+ e.preventDefault();
+ if (acIndex >= 0) items[acIndex]?.classList.remove('active');
+ acIndex = (acIndex + 1) % items.length;
+ items[acIndex]?.classList.add('active');
+ items[acIndex]?.scrollIntoView({ block: 'nearest' });
+ return;
+ }
+
+ if (e.key === 'ArrowUp' && items.length > 0) {
+ e.preventDefault();
+ if (acIndex >= 0) items[acIndex]?.classList.remove('active');
+ acIndex = (acIndex - 1 + items.length) % items.length;
+ items[acIndex]?.classList.add('active');
+ items[acIndex]?.scrollIntoView({ block: 'nearest' });
+ return;
+ }
+
+ e.stopPropagation();
+});
+
+sortConfirmBtn.addEventListener('click', confirmSortAdvanced);
+sortCancelBtn.addEventListener('click', closeSortAdvanced);
+
// ===== File loading =====
async function loadFile(filePath) {
try {
diff --git a/frontend/src/style.css b/frontend/src/style.css
index b858735..ea72bad 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -273,6 +273,124 @@ td.cursor-cell {
border-radius: 3px;
}
+/* Sort Advanced Modal */
+#sort-modal {
+ position: fixed;
+ top: 80px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 460px;
+ background: #fff;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.2);
+ z-index: 100;
+ overflow: visible;
+}
+
+#sort-modal.hidden {
+ display: none;
+}
+
+.modal-title {
+ padding: 12px 16px;
+ font-size: 15px;
+ font-weight: 600;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal-body {
+ padding: 12px 16px;
+}
+
+.modal-body label {
+ display: block;
+ margin-bottom: 6px;
+ font-size: 13px;
+ color: #555;
+}
+
+#sort-input-wrap {
+ position: relative;
+}
+
+#sort-columns-input {
+ width: 100%;
+ padding: 8px 10px;
+ font-size: 14px;
+ font-family: inherit;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ outline: none;
+}
+
+#sort-columns-input:focus {
+ border-color: var(--selected-border);
+ box-shadow: 0 0 0 2px rgba(34,102,204,0.15);
+}
+
+#sort-autocomplete {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 100%;
+ margin-top: 2px;
+ background: #fff;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.12);
+ max-height: 180px;
+ overflow-y: auto;
+ z-index: 110;
+}
+
+#sort-autocomplete.hidden {
+ display: none;
+}
+
+.ac-item {
+ padding: 6px 10px;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.ac-item:hover,
+.ac-item.active {
+ background: var(--selected-bg);
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 10px 16px;
+ border-top: 1px solid var(--border);
+}
+
+.modal-btn {
+ padding: 6px 16px;
+ font-size: 13px;
+ font-family: inherit;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: #fff;
+ cursor: pointer;
+}
+
+.modal-btn:hover {
+ background: #f5f5f5;
+}
+
+.modal-btn-primary {
+ background: var(--selected-border);
+ color: #fff;
+ border-color: var(--selected-border);
+}
+
+.modal-btn-primary:hover {
+ background: #1a54a9;
+}
+
/* Drag over style */
#table-container.drag-over {
background: var(--selected-bg);