Skip to content

Commit 534b191

Browse files
committed
feat(connections): add Test Connection + Edit, split chained sqlite calls
Test Connection button: builds the URI from the live form, pings via connectToMongo, shows green/red status inline without saving. On success the test client is closed so Save/Connect later opens fresh. Edit support: per-card Edit button reopens the form prefilled with the existing profile; the title flips to "Edit Connection" and Save reads "Update Connection". New updateConnection() in the store runs UPDATE WHERE id instead of INSERT. connection-store.ts: split every chained `db.prepare(...).method()` across two statements (initDb, getAll/create/update/delete, saveState/getState). Workaround for a perry codegen pattern where the dropped POINTER-tagged result of a chained .run() aliases the next allocation and crashes — perry/builtin.rs Database ctor + chained Statement dispatch fix in perry v0.5.352 lands the proper path.
1 parent 7be7439 commit 534b191

2 files changed

Lines changed: 204 additions & 74 deletions

File tree

src/app.ts

Lines changed: 146 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { t } from 'perry/i18n';
2121
import { MongoClient } from '@perryts/mongodb';
2222
import { bsonStringify, bsonParse } from './data/bson-json';
2323
import { HoneCodeEditorWidget } from '@honeide/editor/perry';
24-
import { getAllConnections, createConnection, deleteConnection, saveState, getState, setWebTransient } from './data/connection-store';
24+
import { getAllConnections, createConnection, updateConnection, deleteConnection, saveState, getState, setWebTransient } from './data/connection-store';
2525
import { trackAppLaunch, trackConnect, trackQuery } from './data/telemetry';
2626
import { parallelMap, spawn } from 'perry/thread';
2727
import { prettyPrintJson, extractIdShort, extractFields, processDocForDisplay } from './data/json-utils';
@@ -148,6 +148,9 @@ let formPort = '27017';
148148
let formUser = '';
149149
let formPass = '';
150150
let formUri = '';
151+
// Empty string = creating a new connection. Non-empty = editing the existing
152+
// row with this id; Save updates instead of inserts.
153+
let editingConnId = '';
151154

152155
let currentDbName = '';
153156
let currentCollName = '';
@@ -646,14 +649,18 @@ function refreshConnectionList(): void {
646649
widgetSetHidden(confirmNo, 0);
647650
});
648651

652+
const editBtn = Button(t('Edit'), () => { showConnectionForm(connId); });
653+
buttonSetBordered(editBtn, 0);
654+
buttonSetTextColor(editBtn, tsR, tsG, tsB, 1.0);
655+
649656
let card: any;
650657
if (mobile) {
651658
// Mobile: stack vertically — info on top, buttons below
652-
const btnRow = HStack(8, [deleteBtn, Spacer(), connectBtn]);
659+
const btnRow = HStack(8, [editBtn, deleteBtn, Spacer(), connectBtn]);
653660
const confirmRow = HStack(8, [confirmLabel, confirmYes, confirmNo]);
654661
card = VStack(10, [info, confirmRow, btnRow]);
655662
} else {
656-
const row = HStack(12, [accentBar, info, Spacer(), confirmLabel, confirmYes, confirmNo, deleteBtn, connectBtn]);
663+
const row = HStack(12, [accentBar, info, Spacer(), confirmLabel, confirmYes, confirmNo, editBtn, deleteBtn, connectBtn]);
657664
card = VStack(0, [row]);
658665
}
659666
widgetSetBackgroundColor(card, sfR, sfG, sfB, 1.0);
@@ -677,17 +684,86 @@ function refreshConnectionList(): void {
677684
const formContainer = VStack(12, []);
678685
widgetSetHidden(formContainer, 1);
679686

680-
function showConnectionForm(): void {
687+
// Build (uri, displayHost, portNum, name) from the live form fields.
688+
// Shared by Save and Test so both see the exact same values the user
689+
// entered (including unsubmitted edits to TextFields).
690+
function readConnectionForm(
691+
nameField: any, hostField: any, portField: any,
692+
userField: any, passField: any, uriField: any
693+
): { uri: string; name: string; displayHost: string; portNum: number } {
694+
const nameRaw = textfieldGetString(nameField);
695+
const name = (typeof nameRaw === 'string' && nameRaw.length > 0) ? nameRaw : (formName || t('Untitled'));
696+
const hostRaw = textfieldGetString(hostField);
697+
const host = (typeof hostRaw === 'string' && hostRaw.length > 0) ? hostRaw : (formHost || 'localhost');
698+
const portRaw = textfieldGetString(portField);
699+
const port = (typeof portRaw === 'string' && portRaw.length > 0) ? portRaw : (formPort || '27017');
700+
const userStr = textfieldGetString(userField) + '';
701+
const passStr = textfieldGetString(passField) + '';
702+
const uriStr = textfieldGetString(uriField) + '';
703+
704+
let uri = '';
705+
if (uriStr.length > 0) {
706+
uri = uriStr;
707+
} else if (userStr.length > 0 && passStr.length > 0) {
708+
uri = 'mongodb://' + userStr + ':' + passStr + '@' + host + ':' + port;
709+
} else {
710+
uri = 'mongodb://' + host + ':' + port;
711+
}
712+
713+
let displayHost = host;
714+
let displayPort = port;
715+
const schemeIdx = uri.indexOf('://');
716+
if (schemeIdx >= 0) {
717+
const afterScheme = uri.substring(schemeIdx + 3);
718+
const atIdx = afterScheme.indexOf('@');
719+
const hostPart = atIdx >= 0 ? afterScheme.substring(atIdx + 1) : afterScheme;
720+
const slashIdx = hostPart.indexOf('/');
721+
const hostPortStr = slashIdx >= 0 ? hostPart.substring(0, slashIdx) : hostPart;
722+
const colonIdx = hostPortStr.lastIndexOf(':');
723+
if (colonIdx >= 0) {
724+
displayHost = hostPortStr.substring(0, colonIdx);
725+
displayPort = hostPortStr.substring(colonIdx + 1);
726+
} else {
727+
displayHost = hostPortStr;
728+
}
729+
}
730+
731+
let portNum = 27017;
732+
if (displayPort.length > 0) {
733+
const p = Number(displayPort);
734+
if (p > 0) portNum = p;
735+
}
736+
737+
return { uri: uri, name: name, displayHost: displayHost, portNum: portNum };
738+
}
739+
740+
// Find a profile by id from the loaded list. Returns null when not found.
741+
function lookupConnection(id: string): any {
742+
if (!id || id.length === 0) return null;
743+
const all: any = getAllConnections();
744+
for (let i = 0; i < all.length; i++) {
745+
const p: any = all[i];
746+
if (p && p.id === id) return p;
747+
}
748+
return null;
749+
}
750+
751+
function showConnectionForm(editId?: string): void {
681752
widgetClearChildren(formContainer);
682753
widgetSetHidden(connListContainer, 1);
683754
widgetSetHidden(formContainer, 0);
684755

756+
// Resolve edit target (if any). Empty string = new-connection mode.
757+
const wantEdit: string = (typeof editId === 'string' && editId.length > 0) ? editId : '';
758+
const existing: any = wantEdit.length > 0 ? lookupConnection(wantEdit) : null;
759+
editingConnId = existing ? existing.id : '';
760+
685761
const formCard = VStack(12, []);
686762
widgetSetBackgroundColor(formCard, sfR, sfG, sfB, 1.0);
687763
setCornerRadius(formCard, 12);
688764
setPadding(formCard, 20, 24, 20, 24);
689765

690-
const title = makeLabel('New Connection', 18, true);
766+
const title = makeLabel(existing ? t('Edit Connection') : t('New Connection'), 18, true);
691767

692768
const nameLabel = makeSecondary(t('Name'), 11);
693769
const nameField = TextField('e.g. Production, Local dev...', (val: string) => { formName = val; });
@@ -709,70 +785,77 @@ function showConnectionForm(): void {
709785
const uriLabel = makeSecondary(t('Connection String'), 11);
710786
const uriField = TextField('mongodb+srv://user:pass@cluster.example.com/db', (val: string) => { formUri = val; });
711787

788+
// Prefill in edit mode. Only the URI field is restored when present —
789+
// host/port/name come from the stored row. Username/password aren't
790+
// prefilled because they're embedded inside the URI.
791+
if (existing) {
792+
textfieldSetString(nameField, existing.name || '');
793+
textfieldSetString(hostField, existing.host || 'localhost');
794+
textfieldSetString(portField, String(existing.port || 27017));
795+
textfieldSetString(uriField, existing.connectionString || '');
796+
formName = existing.name || '';
797+
formHost = existing.host || 'localhost';
798+
formPort = String(existing.port || 27017);
799+
formUri = existing.connectionString || '';
800+
}
801+
712802
// Tab key navigation: name → host → port → user → pass → uri
713803
textfieldSetNextKeyView(nameField, hostField);
714804
textfieldSetNextKeyView(hostField, portField);
715805
textfieldSetNextKeyView(portField, userField);
716806
textfieldSetNextKeyView(userField, passField);
717807
textfieldSetNextKeyView(passField, uriField);
718808

719-
const saveBtn = Button('Save Connection', () => {
720-
try {
721-
const nameRaw = textfieldGetString(nameField);
722-
const name = (typeof nameRaw === 'string' && nameRaw.length > 0) ? nameRaw : (formName || t('Untitled'));
723-
const hostRaw = textfieldGetString(hostField);
724-
const host = (typeof hostRaw === 'string' && hostRaw.length > 0) ? hostRaw : (formHost || 'localhost');
725-
const portRaw = textfieldGetString(portField);
726-
const port = (typeof portRaw === 'string' && portRaw.length > 0) ? portRaw : (formPort || '27017');
727-
// Build URI using string concatenation (+) which Perry's codegen handles
728-
// correctly for is_string locals. encodeURIComponent and || fail due to
729-
// NaN-boxing being stripped (see PerryTS/perry#10, #12).
730-
// Read user/pass into string variables via + '' (forces string concat path)
731-
const userStr = textfieldGetString(userField) + '';
732-
const passStr = textfieldGetString(passField) + '';
733-
const uriStr = textfieldGetString(uriField) + '';
734-
735-
let uri = '';
736-
if (uriStr.length > 0) {
737-
uri = uriStr;
738-
} else if (userStr.length > 0 && passStr.length > 0) {
739-
// Note: not using encodeURIComponent — it corrupts NaN-boxed strings.
740-
// Users with special chars in user/pass should use the URI field instead.
741-
uri = 'mongodb://' + userStr + ':' + passStr + '@' + host + ':' + port;
742-
} else {
743-
uri = 'mongodb://' + host + ':' + port;
744-
}
745-
746-
// Extract host (without port) from URI for display in connection list
747-
let displayHost = host;
748-
let displayPort = port;
749-
const schemeIdx = uri.indexOf('://');
750-
if (schemeIdx >= 0) {
751-
const afterScheme = uri.substring(schemeIdx + 3);
752-
const atIdx = afterScheme.indexOf('@');
753-
const hostPart = atIdx >= 0 ? afterScheme.substring(atIdx + 1) : afterScheme;
754-
const slashIdx = hostPart.indexOf('/');
755-
const hostPortStr = slashIdx >= 0 ? hostPart.substring(0, slashIdx) : hostPart;
756-
const colonIdx = hostPortStr.lastIndexOf(':');
757-
if (colonIdx >= 0) {
758-
displayHost = hostPortStr.substring(0, colonIdx);
759-
displayPort = hostPortStr.substring(colonIdx + 1);
760-
} else {
761-
displayHost = hostPortStr;
762-
}
809+
// Test-result label: shown by the Test button to report success/failure
810+
// inline without saving. Hidden until first Test press.
811+
const testStatus = Text('');
812+
textSetFontSize(testStatus, 12);
813+
textSetFontFamily(testStatus, uiFont);
814+
textSetWraps(testStatus, 0);
815+
widgetSetHidden(testStatus, 1);
816+
817+
const testBtn = Button(t('Test Connection'), async () => {
818+
const f = readConnectionForm(nameField, hostField, portField, userField, passField, uriField);
819+
textSetString(testStatus, t('Testing...'));
820+
textSetColor(testStatus, tmR, tmG, tmB, 1.0);
821+
widgetSetHidden(testStatus, 0);
822+
const ok = await connectToMongo(f.uri);
823+
if (ok) {
824+
// Don't keep the test connection live — drop it so Save/Connect
825+
// later opens a fresh client. Best-effort close, ignore errors.
826+
if (mongoClient) {
827+
try { await mongoClient.close(); } catch (e: any) {}
828+
mongoClient = null;
829+
currentConnUri = '';
763830
}
831+
textSetString(testStatus, t('Connection OK'));
832+
textSetColor(testStatus, 0.13, 0.55, 0.13, 1.0); // green
833+
} else {
834+
textSetString(testStatus, t('Failed') + ': ' + lastConnError);
835+
textSetColor(testStatus, erR, erG, erB, 1.0);
836+
}
837+
});
838+
buttonSetBordered(testBtn, 0);
839+
buttonSetTextColor(testBtn, moR, moG, moB, 1.0);
764840

765-
// Parse port number (avoid parseInt which may not work in Perry AOT)
766-
let portNum = 27017;
767-
if (displayPort.length > 0) {
768-
const p = Number(displayPort);
769-
if (p > 0) portNum = p;
841+
const saveBtn = Button(existing ? t('Update Connection') : t('Save Connection'), () => {
842+
try {
843+
const f = readConnectionForm(nameField, hostField, portField, userField, passField, uriField);
844+
845+
let savedId: string;
846+
if (editingConnId.length > 0) {
847+
updateConnection(editingConnId, {
848+
name: f.name, host: f.displayHost, port: f.portNum, connectionString: f.uri,
849+
});
850+
savedId = editingConnId;
851+
} else {
852+
const profile: any = createConnection({
853+
name: f.name, host: f.displayHost, port: f.portNum, connectionString: f.uri,
854+
});
855+
savedId = profile.id;
770856
}
771-
772-
// Create the connection profile in SQLite
773-
const profile: any = createConnection({ name: name, host: displayHost, port: portNum, connectionString: uri });
774-
// Also try Keychain as backup
775-
if (!isWeb) keychainSave('mango-conn-' + profile.id, uri);
857+
// Keychain backup of the URI (native only).
858+
if (!isWeb) keychainSave('mango-conn-' + savedId, f.uri);
776859

777860
formName = '';
778861
formHost = 'localhost';
@@ -782,6 +865,7 @@ function showConnectionForm(): void {
782865
saveState('_fu', '');
783866
saveState('_fp', '');
784867
formUri = '';
868+
editingConnId = '';
785869
widgetSetHidden(formContainer, 1);
786870
widgetSetHidden(connListContainer, 0);
787871
loadConnections();
@@ -794,6 +878,7 @@ function showConnectionForm(): void {
794878
buttonSetTextColor(saveBtn, moR, moG, moB, 1.0);
795879

796880
const cancelBtn = makeGhostBtn(t('Cancel'), () => {
881+
editingConnId = '';
797882
widgetSetHidden(formContainer, 1);
798883
widgetSetHidden(connListContainer, 0);
799884
});
@@ -813,7 +898,8 @@ function showConnectionForm(): void {
813898
widgetAddChild(formCard, divLabel);
814899
widgetAddChild(formCard, uriLabel);
815900
widgetAddChild(formCard, uriField);
816-
widgetAddChild(formCard, HStack(8, [cancelBtn, Spacer(), saveBtn]));
901+
widgetAddChild(formCard, testStatus);
902+
widgetAddChild(formCard, HStack(8, [cancelBtn, Spacer(), testBtn, saveBtn]));
817903

818904
widgetAddChild(formContainer, formCard);
819905
widgetMatchParentWidth(formCard);

0 commit comments

Comments
 (0)