Add Sort Advanced command with multi-column sort
Some checks failed
Build / build-linux (push) Failing after 14s
Build / build-linux-webkit2_41 (push) Failing after 14s
Build / build-macos (push) Successful in 3m21s

Opens a modal with a text field supporting autocomplete of column
names. Enter comma-separated column names in priority order;
rows are sorted ascending using locale-aware numeric comparison,
first by the first column, then by the second, and so on.

Autocomplete filters to matching headers, excludes already-chosen
columns, and supports keyboard navigation (arrows + enter).

Co-authored-by: Shelley <shelley@exe.dev>
This commit is contained in:
exe.dev user 2026-03-05 02:41:41 +00:00
parent ae1d428e75
commit ab2d281aad
5 changed files with 333 additions and 2 deletions

View file

@ -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

View file

@ -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: ""},
}
}

View file

@ -25,6 +25,20 @@
<input type="text" id="command-input" placeholder="Type a command..." />
<div id="command-list"></div>
</div>
<div id="sort-modal" class="hidden">
<div class="modal-title">Sort Advanced</div>
<div class="modal-body">
<label for="sort-columns-input">Columns (comma-separated, in priority order):</label>
<div id="sort-input-wrap">
<input type="text" id="sort-columns-input" placeholder="e.g. Name, Age, City" autocomplete="off" />
<div id="sort-autocomplete" class="hidden"></div>
</div>
</div>
<div class="modal-actions">
<button id="sort-cancel-btn" class="modal-btn">Cancel</button>
<button id="sort-confirm-btn" class="modal-btn modal-btn-primary">Sort</button>
</div>
</div>
<div id="overlay" class="hidden"></div>
</div>
<script src="./src/main.js" type="module"></script>

View file

@ -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 {

View file

@ -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);