diff --git a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java index ad80467..0b70356 100644 --- a/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java +++ b/app/src/main/java/com/neatstudio/tmuxandroid/MainActivity.java @@ -3,6 +3,7 @@ package com.neatstudio.tmuxandroid; import android.Manifest; import android.app.Activity; import android.app.AlertDialog; +import android.app.Dialog; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; @@ -14,6 +15,7 @@ import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.StateListDrawable; import android.net.Uri; import android.os.Build; @@ -29,6 +31,7 @@ import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; +import android.view.Window; import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; @@ -127,8 +130,6 @@ public final class MainActivity extends Activity { private TextView terminalText; private TextView terminalMetaText; private TextView terminalConnectionText; - private Button terminalReadingButton; - private Button terminalFullButton; private ScrollView terminalScroll; private LinearLayout terminalChatList; private TextView terminalLiveStatusText; @@ -277,7 +278,7 @@ public final class MainActivity extends Activity { view -> promptCustomServer() )); for (String url : serverUrls) { - content.addView(serverProfileCard(url, !isPresetServer(url)), matchWrap()); + content.addView(serverProfileCard(url), spacedHeight(72, 0, 12)); } content.addView(createServerUtilityRow()); @@ -290,7 +291,7 @@ public final class MainActivity extends Activity { setStatus("Servers"); } - private View serverProfileCard(String url, boolean removable) { + private View serverProfileCard(String url) { String label = url.replace("http://", "").replace(":3000", ""); boolean selected = url.equals(getServerUrl()); LinearLayout card = new LinearLayout(this); @@ -356,13 +357,6 @@ public final class MainActivity extends Activity { stateBlock.addView(active); stateBlock.addView(total); card.addView(stateBlock); - if (removable) { - Button remove = terminalToolButton("×", view -> confirmRemoveSavedServer(url)); - remove.setContentDescription("Remove " + label); - remove.setTextColor(COLOR_DANGER); - card.addView(remove); - } - TextView chevron = new TextView(this); chevron.setText("›"); chevron.setTextColor(COLOR_TEXT_DIM); @@ -370,12 +364,11 @@ public final class MainActivity extends Activity { chevron.setGravity(Gravity.CENTER); card.addView(chevron, new LinearLayout.LayoutParams(dp(24), dp(36))); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - dp(68) - ); - params.bottomMargin = dp(10); - card.setLayoutParams(params); + Button remove = terminalToolButton("×", view -> confirmRemoveSavedServer(url)); + remove.setContentDescription("Remove " + label); + remove.setTextColor(COLOR_DANGER); + card.addView(remove); + return card; } @@ -397,7 +390,6 @@ public final class MainActivity extends Activity { sessionGroupList.setOrientation(LinearLayout.VERTICAL); sessionGroupList.addView(projectStateText("Loading sessions and groups..."), matchWrap()); content.addView(sessionGroupList, matchWrap()); - content.addView(createNewGroupCard(), matchWrap()); scroll.addView(content); root.addView(scroll, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -503,65 +495,25 @@ public final class MainActivity extends Activity { titleBlock.addView(title); row.addView(titleBlock, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); - Button refresh = terminalToolButton("↻", view -> refreshSessions()); - refresh.setContentDescription("Refresh sessions"); - row.addView(refresh); row.addView(primaryButton("+ Session", view -> promptCreateSession()), new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, dp(38) )); header.addView(row, matchWrap()); + + LinearLayout utilities = new LinearLayout(this); + utilities.setOrientation(LinearLayout.HORIZONTAL); + utilities.setGravity(Gravity.END | Gravity.CENTER_VERTICAL); + Button refresh = compactButton("↻ Refresh", view -> refreshSessions()); + refresh.setContentDescription("Refresh sessions"); + utilities.addView(refresh); + Button addGroup = compactButton("+ New group", view -> promptCreateKanbanProject()); + addGroup.setContentDescription("Add group"); + utilities.addView(addGroup); + header.addView(utilities, spacedMatchWrap(8, 0)); return header; } - private View createNewGroupCard() { - LinearLayout card = new LinearLayout(this); - card.setOrientation(LinearLayout.VERTICAL); - card.setPadding(dp(12), dp(11), dp(12), dp(12)); - card.setBackground(cardBackground()); - - TextView label = new TextView(this); - label.setText("NEW GROUP"); - label.setTextColor(COLOR_TEXT_DIM); - label.setTextSize(9); - label.setTypeface(Typeface.MONOSPACE); - card.addView(label); - - LinearLayout row = new LinearLayout(this); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setPadding(0, dp(7), 0, 0); - EditText input = new EditText(this); - input.setHint("group name"); - input.setSingleLine(true); - input.setInputType(InputType.TYPE_CLASS_TEXT); - styleInput(input); - row.addView(input, new LinearLayout.LayoutParams(0, dp(40), 1)); - Button create = compactButton("Create", view -> { - String name = input.getText().toString().trim(); - if (name.isEmpty()) { - showMessage("Group name is empty"); - return; - } - runApiAction("Create kanban project", () -> api.createKanbanProject(name, "~", "")); - }); - LinearLayout.LayoutParams createParams = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - dp(40) - ); - createParams.leftMargin = dp(8); - row.addView(create, createParams); - card.addView(row, matchWrap()); - - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ); - params.topMargin = dp(8); - params.bottomMargin = dp(10); - card.setLayoutParams(params); - return card; - } - private Button primaryButton(String label, View.OnClickListener listener) { Button button = toolbarButton(label, listener); button.setTextColor(Color.rgb(14, 38, 24)); @@ -572,6 +524,10 @@ public final class MainActivity extends Activity { } private String serverDisplayName(String url) { + String savedName = prefs.getString("server_name:" + url, "").trim(); + if (!savedName.isEmpty()) { + return savedName; + } Uri parsed = Uri.parse(url); String host = parsed.getHost(); if (host == null || host.isEmpty()) { @@ -585,28 +541,71 @@ public final class MainActivity extends Activity { } private void promptCustomServer() { - EditText input = new EditText(this); - input.setSingleLine(true); - input.setHint("100.64.0.x:3000"); - input.setText(getServerUrl()); - input.setSelectAllOnFocus(true); - styleInput(input); - new AlertDialog.Builder(this) - .setTitle("Add server") - .setView(input) - .setNegativeButton("Cancel", null) - .setPositiveButton("Connect", (dialog, which) -> { - String url = normalizeServerUrl(input.getText().toString()); - saveCustomServer(url); - selectServer(url); - openSessionPage(); - }) - .show(); + Dialog dialog = new Dialog(this); + LinearLayout sheet = bottomSheetContent(dialog, "Add server", "Save a Tailscale host and connect now"); + EditText name = sheetField(sheet, "NAME", "e.g. build-box", ""); + + LinearLayout endpointLabels = new LinearLayout(this); + endpointLabels.setOrientation(LinearLayout.HORIZONTAL); + endpointLabels.setPadding(0, dp(14), 0, dp(6)); + TextView hostLabel = formLabel("HOST (TAILSCALE IP)"); + endpointLabels.addView(hostLabel, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + TextView portLabel = formLabel("PORT"); + LinearLayout.LayoutParams portLabelParams = new LinearLayout.LayoutParams(dp(82), ViewGroup.LayoutParams.WRAP_CONTENT); + portLabelParams.leftMargin = dp(8); + endpointLabels.addView(portLabel, portLabelParams); + sheet.addView(endpointLabels, matchWrap()); + LinearLayout endpoint = new LinearLayout(this); + endpoint.setOrientation(LinearLayout.HORIZONTAL); + EditText host = new EditText(this); + host.setHint("100.64.0.x"); + host.setSingleLine(true); + host.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + styleInput(host); + endpoint.addView(host, new LinearLayout.LayoutParams(0, dp(44), 1)); + EditText port = new EditText(this); + port.setText("3000"); + port.setSelectAllOnFocus(true); + port.setInputType(InputType.TYPE_CLASS_NUMBER); + styleInput(port); + LinearLayout.LayoutParams portParams = new LinearLayout.LayoutParams(dp(82), dp(44)); + portParams.leftMargin = dp(8); + endpoint.addView(port, portParams); + sheet.addView(endpoint, matchWrap()); + + Button connect = primaryButton("Connect", null); + connect.setOnClickListener(view -> { + String rawHost = host.getText().toString().trim(); + String rawPort = port.getText().toString().trim(); + if (rawHost.isEmpty()) { + host.setError("Host is required"); + return; + } + if (!rawPort.matches("[0-9]{1,5}")) { + port.setError("Invalid port"); + return; + } + String url = normalizeServerUrl(rawHost + ":" + rawPort); + saveCustomServer(url, name.getText().toString().trim()); + dialog.dismiss(); + selectServer(url); + openSessionPage(); + }); + LinearLayout.LayoutParams connectParams = matchWrap(); + connectParams.height = dp(46); + connectParams.topMargin = dp(18); + sheet.addView(connect, connectParams); + showBottomSheet(dialog, sheet, host); } private List savedServerUrls() { List urls = new ArrayList<>(); - Collections.addAll(urls, SERVER_PROFILES); + Set hidden = prefs.getStringSet("hidden_server_urls", Collections.emptySet()); + for (String preset : SERVER_PROFILES) { + if (!hidden.contains(preset)) { + urls.add(preset); + } + } Set custom = prefs.getStringSet("custom_server_urls", Collections.emptySet()); List sortedCustom = new ArrayList<>(custom); Collections.sort(sortedCustom); @@ -616,7 +615,7 @@ public final class MainActivity extends Activity { } } String active = getServerUrl(); - if (!urls.contains(active)) { + if (!urls.contains(active) && !hidden.contains(active)) { urls.add(0, active); } return urls; @@ -631,15 +630,24 @@ public final class MainActivity extends Activity { return false; } - private void saveCustomServer(String url) { - if (isPresetServer(url)) { - return; - } + private void saveCustomServer(String url, String name) { Set custom = new HashSet<>( prefs.getStringSet("custom_server_urls", Collections.emptySet()) ); - custom.add(url); - prefs.edit().putStringSet("custom_server_urls", custom).apply(); + Set hidden = new HashSet<>( + prefs.getStringSet("hidden_server_urls", Collections.emptySet()) + ); + hidden.remove(url); + if (!isPresetServer(url)) { + custom.add(url); + } + SharedPreferences.Editor editor = prefs.edit() + .putStringSet("custom_server_urls", custom) + .putStringSet("hidden_server_urls", hidden); + if (!name.isEmpty()) { + editor.putString("server_name:" + url, name); + } + editor.apply(); } private void confirmRemoveSavedServer(String url) { @@ -651,12 +659,23 @@ public final class MainActivity extends Activity { Set custom = new HashSet<>( prefs.getStringSet("custom_server_urls", Collections.emptySet()) ); + Set hidden = new HashSet<>( + prefs.getStringSet("hidden_server_urls", Collections.emptySet()) + ); custom.remove(url); SharedPreferences.Editor editor = prefs.edit() - .putStringSet("custom_server_urls", custom); + .putStringSet("custom_server_urls", custom) + .remove("server_name:" + url); + if (isPresetServer(url)) { + hidden.add(url); + editor.putStringSet("hidden_server_urls", hidden); + } if (url.equals(getServerUrl())) { - editor.putString("server_url", DEFAULT_TAILSCALE_URL); - api = new SessionApiClient(DEFAULT_TAILSCALE_URL); + List remaining = savedServerUrls(); + remaining.remove(url); + String fallback = remaining.isEmpty() ? BuildConfig.DEFAULT_SERVER_URL : remaining.get(0); + editor.putString("server_url", fallback); + api = new SessionApiClient(fallback); connectAppEvents(); } editor.apply(); @@ -1243,7 +1262,7 @@ public final class MainActivity extends Activity { project.optString("name", "Project"), groupSessions, true - ), matchWrap()); + ), spacedMatchWrap(4, 16)); } } catch (Exception ignored) { } @@ -1254,7 +1273,10 @@ public final class MainActivity extends Activity { ungrouped.add(session); } } - sessionGroupList.addView(sessionGroupSection("Ungrouped", ungrouped, false), matchWrap()); + sessionGroupList.addView( + sessionGroupSection("Ungrouped", ungrouped, false), + spacedMatchWrap(4, 16) + ); setStatus("Loaded " + sessions.size() + " sessions"); } @@ -1303,16 +1325,10 @@ public final class MainActivity extends Activity { section.addView(empty, matchWrap()); } else { for (SessionSummary session : sessions) { - section.addView(sessionRow(session), matchWrap()); + section.addView(sessionRow(session), spacedMatchWrap(0, 10)); } } - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ); - params.bottomMargin = dp(7); - section.setLayoutParams(params); return section; } @@ -1357,7 +1373,7 @@ public final class MainActivity extends Activity { return; } for (int index = 0; index < projects.length(); index++) { - projectList.addView(projectCard(projects.getJSONObject(index)), matchWrap()); + projectList.addView(projectCard(projects.getJSONObject(index))); } } catch (Exception error) { projectList.addView(projectStateText(text == null || text.isEmpty() ? "(empty)" : text), matchWrap()); @@ -1463,7 +1479,7 @@ public final class MainActivity extends Activity { list.addView(empty); } for (SessionSummary session : sessions) { - list.addView(sessionRow(session), matchWrap()); + list.addView(sessionRow(session), spacedMatchWrap(0, 10)); } if (sessionSummaryText != null) { sessionSummaryText.setText("Server: " + getServerUrl() + "\nSessions: " + sessions.size()); @@ -1542,43 +1558,110 @@ public final class MainActivity extends Activity { kill.setTextColor(COLOR_DANGER); row.addView(kill); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ); - params.bottomMargin = dp(7); - row.setLayoutParams(params); return row; } private void promptCreateSession() { - EditText input = new EditText(this); - input.setSingleLine(true); - input.setHint("session-name"); - input.setInputType(InputType.TYPE_CLASS_TEXT); - new AlertDialog.Builder(this) - .setTitle("Create tmux session") - .setView(input) - .setNegativeButton("Cancel", null) - .setPositiveButton("Create", (dialog, which) -> { - String name = input.getText().toString().trim(); - if (!name.matches("[A-Za-z0-9._-]+")) { - showMessage("Invalid session name"); - return; + progressBar.setVisibility(View.VISIBLE); + executor.execute(() -> { + List groups = new ArrayList<>(); + try { + JSONObject object = new JSONObject(api.kanbanProjects()); + JSONArray projects = object.optJSONArray("projects"); + for (int index = 0; projects != null && index < projects.length(); index++) { + JSONObject project = projects.optJSONObject(index); + if (project != null && !project.optString("name").isEmpty()) { + groups.add(project.optString("name")); } - executor.execute(() -> { - try { - api.createSession(name); - runOnUiThread(() -> { - showMessage("Created " + name); - refreshSessions(); - }); - } catch (Exception error) { - runOnUiThread(() -> showMessage("Create failed: " + error.getMessage())); - } + } + } catch (Exception ignored) { + } + runOnUiThread(() -> { + progressBar.setVisibility(View.GONE); + showCreateSessionDialog(groups); + }); + }); + } + + private void showCreateSessionDialog(List groups) { + Dialog dialog = new Dialog(this); + LinearLayout sheet = bottomSheetContent( + dialog, + "New session", + "Create a tmux session and optionally place it in a group" + ); + EditText input = sheetField(sheet, "NAME", "session-name", ""); + + TextView groupLabel = formLabel("GROUP"); + groupLabel.setPadding(0, dp(14), 0, dp(6)); + sheet.addView(groupLabel, matchWrap()); + HorizontalScrollView groupScroller = new HorizontalScrollView(this); + groupScroller.setHorizontalScrollBarEnabled(false); + LinearLayout groupRow = new LinearLayout(this); + groupRow.setOrientation(LinearLayout.HORIZONTAL); + List