mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #811 from l0ng-ai/feat/remote-ports
feat(ports): detect and forward what a remote pane is serving
This commit is contained in:
@@ -614,7 +614,7 @@ impl Default for Config {
|
||||
link_url: true,
|
||||
link_file_open: Some(LinkFileOpen::Internal),
|
||||
link_file_command: None,
|
||||
ssh_loopback_forward: false,
|
||||
ssh_loopback_forward: true,
|
||||
cursor_blink: true,
|
||||
scrollback_limit: 10_000,
|
||||
new_tab_position: NewTabPosition::AfterCurrent,
|
||||
|
||||
@@ -126,6 +126,11 @@ pub mod feature {
|
||||
pub const HOST_RPC: &str = "host-rpc";
|
||||
pub const MACHINE_TREE: &str = "machine-tree";
|
||||
pub const STDIO_BRIDGE: &str = "stdio-bridge";
|
||||
/// The peer can say what is running inside one of its panes, and what that
|
||||
/// is listening on. Without it a remote pane's processes and ports are
|
||||
/// simply unknown here: the pane lives in the peer's registry, and the
|
||||
/// local daemon this client would otherwise ask has never heard of it.
|
||||
pub const PANE_PROCS: &str = "pane-procs";
|
||||
}
|
||||
|
||||
pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
|
||||
@@ -309,6 +314,12 @@ pub enum ControlRequest {
|
||||
},
|
||||
|
||||
AgentStates,
|
||||
/// What is running inside one of the peer's panes, and what it is
|
||||
/// listening on. `pane_id` is the peer's own id for it — the same one the
|
||||
/// client spawned the pane with.
|
||||
PaneProcs {
|
||||
pane_id: u64,
|
||||
},
|
||||
Routes,
|
||||
Status,
|
||||
}
|
||||
@@ -353,7 +364,11 @@ impl ControlRequest {
|
||||
| RepoRoot { .. }
|
||||
| WatchOpen { .. }
|
||||
| WatchSet { .. }
|
||||
| WatchClose { .. } => Duration::from_secs(5),
|
||||
| WatchClose { .. }
|
||||
// A poll, on a two-second timer: waiting longer than the gap
|
||||
// between asks would only stack up requests behind a peer that has
|
||||
// stopped answering.
|
||||
| PaneProcs { .. } => Duration::from_secs(5),
|
||||
ReadFile { .. } | WriteFile { .. } => Duration::from_secs(30),
|
||||
CreateFileNew { .. } | CreateDir { .. } | Rename { .. } | Remove { .. } => {
|
||||
Duration::from_secs(10)
|
||||
@@ -430,6 +445,7 @@ pub enum ReplyOk {
|
||||
TabTree(Box<Tab>),
|
||||
Panes(Vec<u64>),
|
||||
AgentStates(Vec<PaneAgentState>),
|
||||
PaneProcs(crate::daemon::protocol::PaneProcs),
|
||||
Routes(Vec<RouteInfo>),
|
||||
Status(ServerStatus),
|
||||
}
|
||||
@@ -1496,6 +1512,7 @@ mod tests {
|
||||
workspace: None,
|
||||
},
|
||||
ControlRequest::AgentStates,
|
||||
ControlRequest::PaneProcs { pane_id: 7 },
|
||||
ControlRequest::Routes,
|
||||
ControlRequest::Status,
|
||||
]
|
||||
@@ -2226,6 +2243,7 @@ mod tests {
|
||||
s(5),
|
||||
),
|
||||
(R::AgentStates, s(5)),
|
||||
(R::PaneProcs { pane_id: 7 }, s(5)),
|
||||
(R::Routes, s(5)),
|
||||
(R::Status, s(5)),
|
||||
];
|
||||
|
||||
@@ -101,6 +101,10 @@ impl crate::host::server::PaneDirectory for Registry {
|
||||
self.list()
|
||||
}
|
||||
|
||||
fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs {
|
||||
self.get(pane_id).map(|p| p.procs()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn agent_states(&self) -> Vec<crate::daemon::control::PaneAgentState> {
|
||||
let panes: Vec<Arc<DaemonPane>> = self.panes.lock().unwrap().values().cloned().collect();
|
||||
let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect();
|
||||
|
||||
@@ -237,6 +237,17 @@ fn loop_exit_status(exit: LoopExit) -> ForwardStatus {
|
||||
)
|
||||
}
|
||||
|
||||
/// The reason a just-started forward is not listening, if it is not.
|
||||
///
|
||||
/// Only ever a bind failure at this point: everything else that can stop a
|
||||
/// forward happens later, in the accept loop.
|
||||
fn bind_error(status: &SharedStatus) -> Option<String> {
|
||||
match &*status.lock().unwrap() {
|
||||
ForwardStatus::Error(e) => Some(e.clone()),
|
||||
ForwardStatus::Listening => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forward that never got as far as a listening socket. It has no task, so
|
||||
/// nothing will ever move it off this status.
|
||||
fn bind_failed(rule: &SshForwardRule, e: io::Error) -> SharedStatus {
|
||||
@@ -611,18 +622,36 @@ impl SshForwardRegistry {
|
||||
if let Some(local_port) = self.find_auto_local(owner, remote_host, remote_port) {
|
||||
return Ok(LoopbackForward { local_port });
|
||||
}
|
||||
let rule = SshForwardRule {
|
||||
let mut rule = SshForwardRule {
|
||||
kind: SshForwardKind::Local,
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: 0,
|
||||
// The far side's own number first. An automatic forward used to
|
||||
// always bind 0, so the remote's :3000 came out on a different
|
||||
// five-digit port every session — an address nobody could predict,
|
||||
// guess or bookmark. When the number is free here, keeping it makes
|
||||
// localhost:3000 mean what it says.
|
||||
bind_port: remote_port,
|
||||
target_host: remote_host.to_string(),
|
||||
target_port: remote_port,
|
||||
description: Some(format!("localhost link → :{remote_port}")),
|
||||
};
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (bind_port, status, cancel) = self.start_local(&conn, &rule).await;
|
||||
if let ForwardStatus::Error(e) = &*status.lock().unwrap() {
|
||||
return Err(io::Error::other(e.clone()));
|
||||
let (mut bind_port, mut status, mut cancel) = self.start_local(&conn, &rule).await;
|
||||
// Taken here, or a privileged port this process may not bind. Neither
|
||||
// is a reason to fail: 0 asks the OS for one that works, which is what
|
||||
// this did before it tried for the matching number.
|
||||
if let Some(e) = bind_error(&status) {
|
||||
if rule.bind_port == 0 {
|
||||
return Err(io::Error::other(e));
|
||||
}
|
||||
log::debug!(
|
||||
"loopback forward could not keep :{remote_port} locally ({e}); asking the OS for a port"
|
||||
);
|
||||
rule.bind_port = 0;
|
||||
(bind_port, status, cancel) = self.start_local(&conn, &rule).await;
|
||||
if let Some(e) = bind_error(&status) {
|
||||
return Err(io::Error::other(e));
|
||||
}
|
||||
}
|
||||
let entry = ForwardEntry {
|
||||
id,
|
||||
|
||||
@@ -260,6 +260,20 @@ pub trait Host: Send + Sync + 'static {
|
||||
fn is_connected(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// What is running inside one of this host's panes, and what it is
|
||||
/// listening on — or `None` where this host cannot say.
|
||||
///
|
||||
/// `None` is not "nothing is running": it is the answer from a host whose
|
||||
/// panes are somebody else's to describe. The local host gives it, because
|
||||
/// its panes belong to the daemon the caller asks directly; so does a peer
|
||||
/// too old to know the request. Callers must keep the two apart — an empty
|
||||
/// list means the pane really is serving nothing, and drawing "no ports"
|
||||
/// over "we could not ask" is how a remote pane came to look idle while a
|
||||
/// dev server was up in it.
|
||||
fn pane_procs(&self, _pane_id: u64) -> Option<crate::daemon::protocol::PaneProcs> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Did this watch event mean something *changed*, or only that something was
|
||||
|
||||
@@ -253,6 +253,30 @@ impl Host for RemoteHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// The peer owns these panes' PTYs, so it is the one that can walk their
|
||||
/// process trees. A peer that does not announce the feature is not asked:
|
||||
/// it would answer `Err` and the caller cannot tell that apart from a pane
|
||||
/// serving nothing.
|
||||
fn pane_procs(&self, pane_id: u64) -> Option<crate::daemon::protocol::PaneProcs> {
|
||||
if !self
|
||||
.peer()
|
||||
.has_feature(crate::daemon::control::feature::PANE_PROCS)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match self.call(ControlRequest::PaneProcs { pane_id }) {
|
||||
Ok(ReplyOk::PaneProcs(procs)) => Some(procs),
|
||||
Ok(other) => {
|
||||
log::warn!("PaneProcs answered with {other:?}");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("could not read pane {pane_id}'s processes: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&self, p: &Path, recursive: bool) -> io::Result<()> {
|
||||
self.expect_unit(ControlRequest::Remove {
|
||||
path: wire_path(p),
|
||||
|
||||
@@ -28,6 +28,12 @@ pub trait PaneDirectory: Send + Sync {
|
||||
fn pane_count(&self) -> u64;
|
||||
fn panes(&self) -> Vec<PaneInfo>;
|
||||
fn agent_states(&self) -> Vec<PaneAgentState>;
|
||||
/// What is running inside one pane, and what it is listening on.
|
||||
///
|
||||
/// Answered by whoever owns the pane's PTY, which for a remote workspace
|
||||
/// is this process and not the client's own daemon: the client asks over
|
||||
/// the control link precisely because the processes are here.
|
||||
fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -368,6 +374,12 @@ fn handshake<R: Read>(
|
||||
feature::HOST_RPC.to_string(),
|
||||
feature::STDIO_BRIDGE.to_string(),
|
||||
];
|
||||
// Only where there are panes to ask about. A control peer serving no panes
|
||||
// would answer every ask with an empty list, which reads to a client as
|
||||
// "nothing is listening" rather than as "I cannot tell you".
|
||||
if services.panes.is_some() {
|
||||
features.push(feature::PANE_PROCS.to_string());
|
||||
}
|
||||
if services.machine.is_some() {
|
||||
features.push(feature::MACHINE_TREE.to_string());
|
||||
}
|
||||
@@ -831,6 +843,15 @@ fn run_request(
|
||||
),
|
||||
Vec::new(),
|
||||
),
|
||||
ControlRequest::PaneProcs { pane_id } => (
|
||||
ReplyOk::PaneProcs(
|
||||
conn.panes
|
||||
.as_ref()
|
||||
.map(|p| p.pane_procs(pane_id))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
Vec::new(),
|
||||
),
|
||||
ControlRequest::Routes => (
|
||||
ReplyOk::Routes(crate::daemon::ssh::SshManager::global().routes()),
|
||||
Vec::new(),
|
||||
@@ -1676,6 +1697,23 @@ mod aggregate_tests {
|
||||
self.panes.clone()
|
||||
}
|
||||
|
||||
fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs {
|
||||
crate::daemon::protocol::PaneProcs {
|
||||
procs: vec![crate::daemon::protocol::ProcEntry {
|
||||
pid: 900 + pane_id as u32,
|
||||
name: "node".into(),
|
||||
depth: 0,
|
||||
foreground: true,
|
||||
}],
|
||||
ports: vec![crate::daemon::protocol::PortEntry {
|
||||
port: 3000,
|
||||
pid: 900 + pane_id as u32,
|
||||
name: "node".into(),
|
||||
addr: "*".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_states(&self) -> Vec<PaneAgentState> {
|
||||
vec![PaneAgentState {
|
||||
pane_id: 7,
|
||||
@@ -1750,6 +1788,45 @@ mod aggregate_tests {
|
||||
assert_eq!(states[0].state.session_id.as_deref(), Some("sess-7"));
|
||||
}
|
||||
|
||||
/// A remote workspace's pane runs on the peer, so the peer is the only
|
||||
/// one that can walk its process tree — the client's own daemon has never
|
||||
/// heard of the pane. Without this request its ports were simply invisible.
|
||||
#[test]
|
||||
fn a_peer_says_what_is_listening_inside_one_of_its_panes() {
|
||||
let services = Services {
|
||||
panes: Some(Arc::new(ThreePanesOneAgent { panes: Vec::new() })),
|
||||
..Services::none()
|
||||
};
|
||||
let client = client_with(services);
|
||||
|
||||
assert!(
|
||||
client.hello().has_feature(feature::PANE_PROCS),
|
||||
"a peer that serves panes has to say it can describe them"
|
||||
);
|
||||
let ReplyOk::PaneProcs(procs) = client
|
||||
.call(ControlRequest::PaneProcs { pane_id: 4 })
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("PaneProcs must answer with ReplyOk::PaneProcs");
|
||||
};
|
||||
assert_eq!(procs.ports.len(), 1);
|
||||
assert_eq!(procs.ports[0].port, 3000);
|
||||
assert_eq!(
|
||||
procs.ports[0].pid, 904,
|
||||
"the answer is about the pane that was asked for"
|
||||
);
|
||||
}
|
||||
|
||||
/// The feature is the client's only way to tell "nothing is listening"
|
||||
/// from "nobody here can tell you", and a process serving no panes is the
|
||||
/// second. Announcing it anyway would draw an empty Ports list over a
|
||||
/// question that was never answered.
|
||||
#[test]
|
||||
fn a_process_with_no_panes_does_not_claim_it_can_list_ports() {
|
||||
let client = client_with(Services::none());
|
||||
assert!(!client.hello().has_feature(feature::PANE_PROCS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_get_overlays_live_pane_titles() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -139,7 +139,7 @@ angle brackets, and a tab:
|
||||
| `link_url` | bool | `true` | Underline and open URLs on ⌘/Ctrl-click. |
|
||||
| `link_file_open` | string | `internal` | What a file link opens: `internal` (tty7's editor, at the line), `system` (the OS file association), `command`. A file on another machine always uses `internal`. |
|
||||
| `link_file_command` | string | — | Command for file links under `link_file_open: command`. `{path}`, `{line}`, `{column}` are substituted; a flag whose value is missing is dropped. |
|
||||
| `ssh_loopback_forward` | bool | `false` | Open `localhost:PORT` links through a temporary forward when the pane is in SSH. |
|
||||
| `ssh_loopback_forward` | bool | `true` | Forward the ports a remote pane starts serving, and open its `localhost:PORT` links through those forwards. |
|
||||
|
||||
## Notifications
|
||||
|
||||
|
||||
@@ -3,6 +3,37 @@ title: "Port forwarding"
|
||||
description: "Local, remote, and dynamic forwards — preconfigured or added mid-session."
|
||||
---
|
||||
|
||||
## Ports you did not ask to forward
|
||||
|
||||
When a pane is on a remote machine, tty7 watches what its processes are
|
||||
listening on and forwards those ports for you. Start a dev server on the remote
|
||||
`:3000` and a moment later a notice says it is at `http://localhost:3000` —
|
||||
same number, no rule to write.
|
||||
|
||||
The **Ports** section of the Info panel is the whole list: one row per listener,
|
||||
the process that owns it, and where it comes out on this machine. The globe
|
||||
opens it in a browser, the copy tile takes the address that works from here,
|
||||
and ✕ takes the forward back down.
|
||||
|
||||
Ports are forwarded once each. Remove one and it stays removed — the offer is
|
||||
made a single time per port, not on every poll.
|
||||
|
||||
The local port matches the remote one whenever it is free here. When it is not —
|
||||
something else on this machine already has `:3000` — the OS picks another and
|
||||
the row says which.
|
||||
|
||||
Turn the whole thing off under **Settings → Terminal → Links → Forward remote
|
||||
ports** (`ssh_loopback_forward: false`).
|
||||
|
||||
<Note>
|
||||
In a remote workspace the ports are found by the **`tty7-server` on that
|
||||
machine** — the panes are its, and this machine's daemon has never heard of
|
||||
them. A server too old to answer says so in the Ports section rather than
|
||||
showing an empty list; updating it is what fixes that. Panes that ssh'd
|
||||
somewhere from inside a local pane are a different case: those processes
|
||||
belong to no tty7 server, so nothing lists them.
|
||||
</Note>
|
||||
|
||||
## The three kinds
|
||||
|
||||
| | What it does |
|
||||
@@ -25,23 +56,26 @@ later `8080 → 3000` explains nothing.
|
||||
|
||||
## Adding one mid-session
|
||||
|
||||
*SSH: Port Forwarding* in the command palette opens the **Forwards** panel for
|
||||
the current connection. Add a rule there and it starts immediately; remove it
|
||||
and it stops. These live only as long as the session unless you save them into
|
||||
the profile.
|
||||
The **+** on the Ports section — or *SSH: Port Forwarding* in the command
|
||||
palette — opens a form asking for one thing: the port the remote is serving on.
|
||||
It comes out here under the same number, and the form says so before you commit
|
||||
to it.
|
||||
|
||||
<Frame caption="Placeholder — screenshot: the Forwards panel with a local and a dynamic rule">
|
||||
<img src="/images/placeholder.svg" alt="The forwards panel" />
|
||||
**Advanced** opens the rest of the grammar: remote and dynamic forwards, a bind
|
||||
host other than loopback, a target on some third machine, a description. You
|
||||
need it about as often as you need `ssh -R`.
|
||||
|
||||
These live only as long as the session unless you save them into the profile.
|
||||
|
||||
<Frame caption="Placeholder — screenshot: the Ports section with a forwarded port and a dynamic rule">
|
||||
<img src="/images/placeholder.svg" alt="The ports section" />
|
||||
</Frame>
|
||||
|
||||
## The one-click shortcut
|
||||
## Links in the terminal
|
||||
|
||||
<kbd>⌘</kbd>-clicking a `localhost:PORT` link inside an SSH pane can open a
|
||||
temporary forward for exactly that port and then open the browser — turn on
|
||||
**Settings → Terminal → Links → Forward SSH loopback links**.
|
||||
|
||||
That is the right tool for "let me look at this dev server once". For something
|
||||
you use every day, put it in the profile.
|
||||
<kbd>⌘</kbd>-clicking a `localhost:PORT` link inside a remote pane opens the
|
||||
forward for that port if there is not one yet, then the browser. Same machinery
|
||||
as the Ports list, reached from the output instead of the panel.
|
||||
|
||||
## Jump hosts and proxies
|
||||
|
||||
|
||||
+10
-6
@@ -67,12 +67,16 @@ that did not moves to the built-in editor.
|
||||
<kbd>⌘</kbd>-clicking `localhost:3000` opens it in your browser, which is only
|
||||
useful if the server is on this machine.
|
||||
|
||||
When the pane is inside an SSH session it usually is not. Turn on **Settings →
|
||||
Terminal → Links → Forward SSH loopback links** (`ssh_loopback_forward: true`)
|
||||
and tty7 opens a temporary port forward through that connection first, so the
|
||||
link reaches the server on the remote machine.
|
||||
When the pane is inside an SSH session it usually is not, so tty7 opens a port
|
||||
forward through that connection first and points the browser at the local end.
|
||||
The forward is built on the click if it is not there already.
|
||||
|
||||
This is on by default; **Settings → Terminal → Links → Forward remote ports**
|
||||
(`ssh_loopback_forward`) turns it off, along with the automatic forwarding of
|
||||
ports a remote pane starts serving.
|
||||
|
||||
<Tip>
|
||||
For a forward you want to keep, set one up properly instead —
|
||||
[port forwarding](/remote/port-forwarding).
|
||||
Every port a remote pane is listening on is listed in the Info panel, whether
|
||||
or not its URL was ever printed — see [port
|
||||
forwarding](/remote/port-forwarding).
|
||||
</Tip>
|
||||
|
||||
+85
-29
@@ -481,6 +481,18 @@ enum LinkAt {
|
||||
None,
|
||||
}
|
||||
|
||||
/// What it takes to open one of a pane's loopback ports from this machine.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum PortRoute {
|
||||
/// The port is already reachable here under its own number.
|
||||
Direct,
|
||||
/// A local forward has to exist first; the pane's `ForwardRoute` builds it.
|
||||
Forward,
|
||||
/// Another machine's port, with no way to reach it from here.
|
||||
#[default]
|
||||
Blocked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum LoopbackPlan {
|
||||
Direct,
|
||||
@@ -489,6 +501,26 @@ pub(super) enum LoopbackPlan {
|
||||
ForwardOnWorkspace(Box<crate::terminal::PaneWorkspace>),
|
||||
}
|
||||
|
||||
/// What a pane's loopback port takes to open, given how its links would be
|
||||
/// forwarded and whether the daemon listing it is this machine's.
|
||||
///
|
||||
/// Deliberately not "is the pane local": a remote pane's :3000 is perfectly
|
||||
/// reachable once a forward exists, and building that forward is something
|
||||
/// this app already knows how to do. Answering only "local or not" is what
|
||||
/// left the Ports list showing a port it then refused to open.
|
||||
pub(crate) fn port_route_of(plan: &LoopbackPlan, local: bool) -> PortRoute {
|
||||
match plan {
|
||||
// WSL shares this machine's loopback, so its ports are already here
|
||||
// under the same number.
|
||||
LoopbackPlan::NoForwardNeeded => PortRoute::Direct,
|
||||
LoopbackPlan::ForwardOnPane(_) | LoopbackPlan::ForwardOnWorkspace(_) => PortRoute::Forward,
|
||||
// No plan and no forwarding: this machine's own ports open, and
|
||||
// another machine's do not.
|
||||
LoopbackPlan::Direct if local => PortRoute::Direct,
|
||||
LoopbackPlan::Direct => PortRoute::Blocked,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn loopback_plan(
|
||||
enabled: bool,
|
||||
workspace: Option<&crate::terminal::PaneWorkspace>,
|
||||
@@ -5643,12 +5675,9 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
let forwarded = match &plan {
|
||||
LoopbackPlan::ForwardOnPane(pane_id) => RemoteTerminal::ensure_loopback_forward(
|
||||
*pane_id,
|
||||
loopback.forward_host(),
|
||||
loopback.port,
|
||||
),
|
||||
LoopbackPlan::ForwardOnWorkspace(ws) => self.ensure_workspace_loopback(ws, &loopback),
|
||||
LoopbackPlan::ForwardOnPane(_) | LoopbackPlan::ForwardOnWorkspace(_) => self
|
||||
.forward_route()
|
||||
.ensure_loopback(loopback.forward_host(), loopback.port),
|
||||
LoopbackPlan::Direct | LoopbackPlan::NoForwardNeeded => unreachable!("handled above"),
|
||||
};
|
||||
match forwarded {
|
||||
@@ -5666,27 +5695,14 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_workspace_loopback(
|
||||
&self,
|
||||
ws: &crate::terminal::PaneWorkspace,
|
||||
loopback: &super::loopback::LoopbackUrl,
|
||||
) -> anyhow::Result<crate::daemon::protocol::LoopbackForward> {
|
||||
let req = RemoteTerminal::workspace_request(
|
||||
ws,
|
||||
self.pane_id,
|
||||
crate::daemon::protocol::WorkspaceOp::EnsureLoopback {
|
||||
remote_host: loopback.forward_host().to_string(),
|
||||
remote_port: loopback.port,
|
||||
},
|
||||
)
|
||||
.ok_or_else(|| anyhow::anyhow!("this workspace has no SSH connection to forward over"))?;
|
||||
match RemoteTerminal::on_workspace(req)? {
|
||||
crate::daemon::protocol::DaemonMsg::LoopbackForward(f) => Ok(f),
|
||||
other => Err(anyhow::anyhow!("unexpected reply: {other:?}")),
|
||||
}
|
||||
/// How forward requests about this pane reach the daemon that owns them —
|
||||
/// through the workspace when there is one, and by pane id when there is
|
||||
/// not. The Ports list and the port watcher build the same thing.
|
||||
pub(crate) fn forward_route(&self) -> crate::ui::app::ForwardRoute {
|
||||
crate::ui::app::ForwardRoute::new(self.pane_id, self.workspace.clone())
|
||||
}
|
||||
|
||||
fn loopback_plan(&self, cx: &mut Context<Self>) -> LoopbackPlan {
|
||||
fn loopback_plan(&self, cx: &gpui::App) -> LoopbackPlan {
|
||||
loopback_plan(
|
||||
cx.global::<Config>().ssh_loopback_forward,
|
||||
self.workspace.as_ref(),
|
||||
@@ -5695,10 +5711,21 @@ impl TerminalView {
|
||||
)
|
||||
}
|
||||
|
||||
fn can_forward_loopback(&self, cx: &mut Context<Self>) -> bool {
|
||||
fn can_forward_loopback(&self, cx: &gpui::App) -> bool {
|
||||
!matches!(self.loopback_plan(cx), LoopbackPlan::Direct)
|
||||
}
|
||||
|
||||
/// How a loopback port this pane is serving can be reached from here.
|
||||
///
|
||||
/// The Ports list asks this about every listener it found, and it is a
|
||||
/// different question from "is this pane local": a remote pane's :3000 is
|
||||
/// perfectly reachable once a forward exists, and building that forward is
|
||||
/// something this app already knows how to do. Answering only "local or
|
||||
/// not" is what left the list showing a port it refused to open.
|
||||
pub(crate) fn port_route(&self, cx: &gpui::App) -> PortRoute {
|
||||
port_route_of(&self.loopback_plan(cx), self.host_id().is_local())
|
||||
}
|
||||
|
||||
pub fn hover_link_at(
|
||||
&mut self,
|
||||
col: usize,
|
||||
@@ -7579,9 +7606,10 @@ mod tests {
|
||||
assert!(!out.contains(" …"), "{out:?}");
|
||||
}
|
||||
use super::{
|
||||
COMPLETION_MENU_MAX_W, LoopbackPlan, RawInput, SelectEndCopy, Typeahead, WheelRoute,
|
||||
clipboard_paste_text, compose_notification_title, cwd_is_on_host, display_width,
|
||||
is_typeahead_interrupt, link_path_style, loopback_plan, observe_typeahead_for_owner,
|
||||
COMPLETION_MENU_MAX_W, LoopbackPlan, PortRoute, RawInput, SelectEndCopy, Typeahead,
|
||||
WheelRoute, clipboard_paste_text, compose_notification_title, cwd_is_on_host,
|
||||
display_width, is_typeahead_interrupt, link_path_style, loopback_plan,
|
||||
observe_typeahead_for_owner,
|
||||
};
|
||||
use super::{SCROLL_ANIM_FRAME, scroll_anim_step};
|
||||
use super::{
|
||||
@@ -7818,6 +7846,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// What the Ports list asks about every listener it found. The middle
|
||||
/// case is the one that matters: a remote pane's port is not unreachable,
|
||||
/// it is one forward away.
|
||||
#[test]
|
||||
fn a_remote_port_is_a_forward_away_rather_than_out_of_reach() {
|
||||
use super::port_route_of;
|
||||
assert_eq!(
|
||||
port_route_of(&LoopbackPlan::ForwardOnPane(7), false),
|
||||
PortRoute::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
port_route_of(&LoopbackPlan::NoForwardNeeded, false),
|
||||
PortRoute::Direct,
|
||||
"WSL serves onto this machine's own loopback"
|
||||
);
|
||||
assert_eq!(
|
||||
port_route_of(&LoopbackPlan::Direct, true),
|
||||
PortRoute::Direct,
|
||||
"this machine's ports open with no help"
|
||||
);
|
||||
assert_eq!(
|
||||
port_route_of(&LoopbackPlan::Direct, false),
|
||||
PortRoute::Blocked,
|
||||
"another machine's port with forwarding turned off is not ours to \
|
||||
open — opening it here would reach some unrelated local service"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_workspace_needs_no_forward() {
|
||||
let w = ws(
|
||||
|
||||
+171
-32
@@ -763,10 +763,18 @@ pub(crate) struct LoopbackForwardPanelState {
|
||||
/// Why the last Add or Save did not take, in the far side's own words.
|
||||
/// Cleared the moment the form is closed or the edit is abandoned.
|
||||
pub(crate) mf_error: Option<String>,
|
||||
/// Return, on each of the five boxes. Held here for the same reason the
|
||||
/// sftp form holds its own: a live subscription on a box nothing is
|
||||
/// showing would answer Return for a form that is gone.
|
||||
/// Return, on each of the boxes. Held here for the same reason the sftp
|
||||
/// form holds its own: a live subscription on a box nothing is showing
|
||||
/// would answer Return for a form that is gone.
|
||||
pub(crate) mf_subs: Vec<Subscription>,
|
||||
/// Whether the form is showing all five fields rather than the one.
|
||||
///
|
||||
/// Almost every forward anyone builds by hand is "bring the remote's :3000
|
||||
/// over here", which is one number — and asking for five fields to collect
|
||||
/// one number is what made the panel feel like paperwork. The rest of the
|
||||
/// `ssh -L` grammar is still here, one disclosure away, for the forwards
|
||||
/// that really do need it.
|
||||
pub(crate) mf_advanced: bool,
|
||||
}
|
||||
|
||||
pub struct Tty7App {
|
||||
@@ -1427,6 +1435,7 @@ impl Tty7App {
|
||||
mf_editing: None,
|
||||
mf_error: None,
|
||||
mf_subs: Vec::new(),
|
||||
mf_advanced: false,
|
||||
},
|
||||
sftp_panel,
|
||||
right_panel: Default::default(),
|
||||
@@ -2794,6 +2803,7 @@ impl Tty7App {
|
||||
pub(crate) fn managed_forward_fields(&self, cx: &gpui::App) -> ForwardFields {
|
||||
let val = |input: &Entity<InputState>| input.read(cx).value().to_string();
|
||||
ForwardFields {
|
||||
advanced: self.loopback_panel.mf_advanced,
|
||||
kind: self.loopback_panel.mf_kind,
|
||||
bind_host: val(&self.loopback_panel.mf_bind_host),
|
||||
bind_port: val(&self.loopback_panel.mf_bind_port),
|
||||
@@ -2809,9 +2819,8 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
use crate::daemon::protocol::ForwardStatus;
|
||||
|
||||
let Some(rule) = self.managed_forward_fields(cx).collect() else {
|
||||
let fields = self.managed_forward_fields(cx);
|
||||
let Some(rule) = fields.collect() else {
|
||||
// Add is disabled while the fields do not make a rule and the form
|
||||
// already says what is missing, so there is nothing to do here and
|
||||
// nothing left to explain.
|
||||
@@ -2838,31 +2847,32 @@ impl Tty7App {
|
||||
self.loopback_panel.managed = list;
|
||||
}
|
||||
|
||||
let before: Vec<u64> = self.loopback_panel.managed.iter().map(|m| m.id).collect();
|
||||
let mut failure = None;
|
||||
match route.add(rule) {
|
||||
// The request never got an answer. An empty list here is not "this
|
||||
// pane has no forwards", it is "nobody said" — assigning it is what
|
||||
// used to blank the panel on a dropped connection.
|
||||
None => failure = Some(t(L10nKey::ForwardRequestFailed).to_string()),
|
||||
Some(list) => {
|
||||
// A rule that could not be started is registered all the same,
|
||||
// with the reason in its status, so whether the add worked is a
|
||||
// question about the entry it appended rather than about
|
||||
// whether the call returned.
|
||||
let broken = added_forward(&before, &list).and_then(|added| match &added.status {
|
||||
ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
|
||||
ForwardStatus::Listening => None,
|
||||
});
|
||||
self.loopback_panel.managed = list;
|
||||
if let Some((id, msg)) = broken {
|
||||
if let Some(list) = route.remove(id) {
|
||||
self.loopback_panel.managed = list;
|
||||
}
|
||||
failure = Some(msg);
|
||||
// The short form aims at the same number on both ends because that is
|
||||
// the address people can predict. When it is already taken here, the
|
||||
// useful answer is another port rather than a complaint: somebody who
|
||||
// typed one number to forward one port has not been asked to care
|
||||
// which local port it lands on, and the row says where it came out.
|
||||
let retry_free_port = !fields.advanced && rule.bind_port != 0;
|
||||
let mut failure = match self.place_forward(&route, rule.clone()) {
|
||||
PlaceOutcome::Placed => None,
|
||||
// Nobody answered, so nothing was bound and nothing would be bound
|
||||
// by asking again — a second round trip would only spend another
|
||||
// timeout on the way to the same sentence.
|
||||
PlaceOutcome::Unreachable(msg) => Some(msg),
|
||||
PlaceOutcome::Rejected(msg) if !retry_free_port => Some(msg),
|
||||
PlaceOutcome::Rejected(_) => {
|
||||
match self.place_forward(
|
||||
&route,
|
||||
crate::daemon::protocol::SshForwardRule {
|
||||
bind_port: 0,
|
||||
..rule.clone()
|
||||
},
|
||||
) {
|
||||
PlaceOutcome::Placed => None,
|
||||
PlaceOutcome::Rejected(msg) | PlaceOutcome::Unreachable(msg) => Some(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(msg) = failure {
|
||||
// Put back what the edit took out, so the worst a failed Save can
|
||||
@@ -2900,6 +2910,41 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Ask the far side for one rule, and say what became of it.
|
||||
///
|
||||
/// A rule that could not be started is registered all the same, with the
|
||||
/// reason in its status, so whether the add worked is a question about the
|
||||
/// entry it appended rather than about whether the call returned. The dead
|
||||
/// entry is taken back out — a forward listed as listening on nothing is
|
||||
/// worse than no forward.
|
||||
fn place_forward(
|
||||
&mut self,
|
||||
route: &ForwardRoute,
|
||||
rule: crate::daemon::protocol::SshForwardRule,
|
||||
) -> PlaceOutcome {
|
||||
use crate::daemon::protocol::ForwardStatus;
|
||||
|
||||
let before: Vec<u64> = self.loopback_panel.managed.iter().map(|m| m.id).collect();
|
||||
// The request never got an answer. An empty list here is not "this
|
||||
// pane has no forwards", it is "nobody said" — assigning it is what
|
||||
// used to blank the panel on a dropped connection.
|
||||
let Some(list) = route.add(rule) else {
|
||||
return PlaceOutcome::Unreachable(t(L10nKey::ForwardRequestFailed).to_string());
|
||||
};
|
||||
let broken = added_forward(&before, &list).and_then(|added| match &added.status {
|
||||
ForwardStatus::Error(msg) => Some((added.id, msg.clone())),
|
||||
ForwardStatus::Listening => None,
|
||||
});
|
||||
self.loopback_panel.managed = list;
|
||||
let Some((id, msg)) = broken else {
|
||||
return PlaceOutcome::Placed;
|
||||
};
|
||||
if let Some(list) = route.remove(id) {
|
||||
self.loopback_panel.managed = list;
|
||||
}
|
||||
PlaceOutcome::Rejected(msg)
|
||||
}
|
||||
|
||||
pub(crate) fn edit_managed_forward(
|
||||
&mut self,
|
||||
forward: crate::daemon::protocol::ManagedForward,
|
||||
@@ -2909,6 +2954,10 @@ impl Tty7App {
|
||||
self.loopback_panel.mf_kind = forward.kind;
|
||||
self.loopback_panel.form_pane_id = Some(forward.pane_id);
|
||||
self.loopback_panel.mf_error = None;
|
||||
// A rule that already exists is shown whole: the short form cannot
|
||||
// spell a bind host or a remote forward, so editing one through it
|
||||
// would silently rewrite the parts it cannot see.
|
||||
self.loopback_panel.mf_advanced = true;
|
||||
let target_port = if forward.target_port == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -2974,12 +3023,19 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some((pane_id, _)) = self.active_connected_native_ssh_pane(window, cx) else {
|
||||
// Whatever the panel would let this pane forward, which is a wider set
|
||||
// than "a connected native-ssh pane": a pane in a remote workspace
|
||||
// forwards over the workspace's own connection, and the command used
|
||||
// to do nothing at all there while the panel beside it worked.
|
||||
let Some(ctx) = self.pane_forward_ctx(window, cx) else {
|
||||
return;
|
||||
};
|
||||
if ctx.route.is_none() {
|
||||
return;
|
||||
}
|
||||
self.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx);
|
||||
if self.loopback_panel.form_pane_id != Some(pane_id) {
|
||||
self.toggle_managed_forward_form(pane_id, window, cx);
|
||||
if self.loopback_panel.form_pane_id != Some(ctx.pane_id) {
|
||||
self.toggle_managed_forward_form(ctx.pane_id, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2994,6 +3050,8 @@ impl Tty7App {
|
||||
return;
|
||||
}
|
||||
self.loopback_panel.form_pane_id = Some(pane_id);
|
||||
self.loopback_panel.mf_advanced = false;
|
||||
self.loopback_panel.mf_kind = crate::daemon::protocol::SshForwardKind::Local;
|
||||
self.cancel_managed_forward_edit(window, cx);
|
||||
self.refresh_managed_forwards(pane_id, cx);
|
||||
self.arm_managed_forward_form(pane_id, window, cx);
|
||||
@@ -3041,6 +3099,12 @@ impl Tty7App {
|
||||
inputs[0].update(cx, |s, cx| s.focus(window, cx));
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_managed_forward_advanced(&mut self, cx: &mut Context<Self>) {
|
||||
self.loopback_panel.mf_advanced = !self.loopback_panel.mf_advanced;
|
||||
self.loopback_panel.mf_error = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn close_managed_forward_form(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
@@ -7149,7 +7213,43 @@ pub(crate) struct ForwardRoute {
|
||||
workspace: Option<crate::terminal::PaneWorkspace>,
|
||||
}
|
||||
|
||||
/// What came of asking the far side to put one forward up.
|
||||
enum PlaceOutcome {
|
||||
Placed,
|
||||
/// The far side answered and the rule could not be started — a bind that
|
||||
/// collided, a port this process may not have. Another bind port might.
|
||||
Rejected(String),
|
||||
/// Nobody answered. Nothing was bound, and nothing about the rule is what
|
||||
/// went wrong.
|
||||
Unreachable(String),
|
||||
}
|
||||
|
||||
/// Who a set of forwards belongs to on the far side.
|
||||
///
|
||||
/// A workspace's forwards outlive any one of its panes and are shared between
|
||||
/// all of them, so "have we already offered to forward :3000" is a question
|
||||
/// about the workspace — asking it per pane made switching tabs re-announce
|
||||
/// every port the workspace had already forwarded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum ForwardOwnerKey {
|
||||
Pane(u64),
|
||||
Workspace(crate::core::session::WorkspaceId),
|
||||
}
|
||||
|
||||
impl ForwardRoute {
|
||||
pub(crate) fn new(pane_id: u64, workspace: Option<crate::terminal::PaneWorkspace>) -> Self {
|
||||
Self { pane_id, workspace }
|
||||
}
|
||||
|
||||
/// Which side of the daemon's own forward registry this route addresses —
|
||||
/// the same split `ForwardOwner` makes there.
|
||||
pub(crate) fn owner_key(&self) -> ForwardOwnerKey {
|
||||
match &self.workspace {
|
||||
Some(ws) => ForwardOwnerKey::Workspace(ws.workspace),
|
||||
None => ForwardOwnerKey::Pane(self.pane_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_op(
|
||||
&self,
|
||||
op: crate::daemon::protocol::WorkspaceOp,
|
||||
@@ -7213,6 +7313,37 @@ impl ForwardRoute {
|
||||
Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The local port that reaches `remote_host:remote_port` over this route,
|
||||
/// building the forward if there is not one yet.
|
||||
///
|
||||
/// The far side keeps one automatic forward per endpoint and hands the
|
||||
/// same port back on the next ask, so callers may treat this as "what is
|
||||
/// the address here" rather than as an action with a cost — which is what
|
||||
/// lets the Ports list call it on a click and the watcher call it on a
|
||||
/// port it has only just noticed.
|
||||
pub(crate) fn ensure_loopback(
|
||||
&self,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> anyhow::Result<crate::daemon::protocol::LoopbackForward> {
|
||||
let Some(req) = self.workspace_op(crate::daemon::protocol::WorkspaceOp::EnsureLoopback {
|
||||
remote_host: remote_host.to_string(),
|
||||
remote_port,
|
||||
}) else {
|
||||
return crate::terminal::RemoteTerminal::ensure_loopback_forward(
|
||||
self.pane_id,
|
||||
remote_host,
|
||||
remote_port,
|
||||
);
|
||||
};
|
||||
match crate::terminal::RemoteTerminal::on_workspace(req)? {
|
||||
crate::daemon::protocol::DaemonMsg::LoopbackForward(f) => Ok(f),
|
||||
other => Err(anyhow::anyhow!(
|
||||
"unexpected reply to EnsureLoopback: {other:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove(
|
||||
&self,
|
||||
forward_id: u64,
|
||||
@@ -7240,6 +7371,10 @@ impl Render for Tty7App {
|
||||
self.touch_active_tab();
|
||||
self.declare_displayed_panes(cx);
|
||||
self.scm_sync_watchers(window, cx);
|
||||
// Keeps looking for new listening ports on the pane in front, panel
|
||||
// open or not — a port that appears while the panel is shut is exactly
|
||||
// the one worth forwarding unasked.
|
||||
self.sync_port_watch(window, cx);
|
||||
if cx.has_active_drag() {
|
||||
crate::ui::reorder::clear_pending(&self.reorder);
|
||||
crate::ui::pane_drag::clear_landing(&self.pane_drag);
|
||||
@@ -10592,6 +10727,9 @@ mod managed_forward_gpui_tests {
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
app.loopback_panel.managed = vec![listening(1)];
|
||||
app.loopback_panel.form_pane_id = Some(1);
|
||||
// These three fields are the long form's; without this the short
|
||||
// form would read them as the one number it asks for.
|
||||
app.loopback_panel.mf_advanced = true;
|
||||
let typed: [(&gpui::Entity<InputState>, &str); 3] = [
|
||||
(&app.loopback_panel.mf_bind_port, "9000"),
|
||||
(&app.loopback_panel.mf_target_host, "127.0.0.1"),
|
||||
@@ -10628,6 +10766,7 @@ mod managed_forward_gpui_tests {
|
||||
app.loopback_panel.managed = vec![listening(1)];
|
||||
app.loopback_panel.form_pane_id = Some(1);
|
||||
app.loopback_panel.mf_editing = Some(listening(1));
|
||||
app.loopback_panel.mf_advanced = true;
|
||||
let typed: [(&gpui::Entity<InputState>, &str); 3] = [
|
||||
(&app.loopback_panel.mf_bind_port, "8080"),
|
||||
(&app.loopback_panel.mf_target_host, "10.0.0.6"),
|
||||
|
||||
+207
-137
@@ -1,15 +1,13 @@
|
||||
use gpui::{AnyElement, Context, Div, Entity, FontWeight, Stateful, div, prelude::*, px, rems};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
||||
|
||||
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind, SshForwardRule};
|
||||
use crate::terminal::view::TerminalView;
|
||||
use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App};
|
||||
use crate::ui::app::{CONTENT_INSET, Tty7App};
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
use crate::ui::right_panel::{META, TEXT, TEXT_MONO};
|
||||
use crate::ui::right_panel::{META, TEXT_MONO};
|
||||
|
||||
/// How far a forward's target endpoint fades when the rule is Dynamic and has
|
||||
/// no target to name. The rules editor in Settings and the live Forwards panel
|
||||
@@ -23,6 +21,9 @@ pub(crate) const NO_TARGET_FADE: f32 = 0.4;
|
||||
/// rule, and `forward_form` needs to know whether there is one yet — that is
|
||||
/// what decides whether Add is live and whether the form says what is missing.
|
||||
pub(crate) struct ForwardFields {
|
||||
/// Whether the whole `ssh -L` grammar is on screen. With it off the form
|
||||
/// is one number and the rule is derived from it.
|
||||
pub(crate) advanced: bool,
|
||||
pub(crate) kind: SshForwardKind,
|
||||
pub(crate) bind_host: String,
|
||||
pub(crate) bind_port: String,
|
||||
@@ -41,6 +42,9 @@ impl ForwardFields {
|
||||
/// OS to pick the port, and there is nowhere in either form to say which
|
||||
/// one it picked.
|
||||
pub(crate) fn collect(&self) -> Option<SshForwardRule> {
|
||||
if !self.advanced {
|
||||
return self.simple_rule();
|
||||
}
|
||||
let bind_port: u16 = self.bind_port.trim().parse().ok().filter(|p| *p > 0)?;
|
||||
let (target_host, target_port) = if self.kind == SshForwardKind::Dynamic {
|
||||
(String::new(), 0)
|
||||
@@ -69,10 +73,36 @@ impl ForwardFields {
|
||||
})
|
||||
}
|
||||
|
||||
/// The rule the short form's single number describes: bring the far
|
||||
/// side's own `:port` over to the same number here.
|
||||
///
|
||||
/// The bind port is that same number rather than 0 so the address is one
|
||||
/// anybody can predict — remote :3000 is localhost:3000. When it is taken
|
||||
/// on this machine `add_managed_forward` retries with 0 and the OS picks,
|
||||
/// which is a better answer than handing the collision back to be solved
|
||||
/// by hand.
|
||||
fn simple_rule(&self) -> Option<SshForwardRule> {
|
||||
let port: u16 = self.target_port.trim().parse().ok().filter(|p| *p > 0)?;
|
||||
Some(SshForwardRule {
|
||||
kind: SshForwardKind::Local,
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: port,
|
||||
target_host: "localhost".to_string(),
|
||||
target_port: port,
|
||||
// No field for it on screen, so nothing to carry: a description
|
||||
// left over from a trip through the advanced form is not something
|
||||
// this rule was given.
|
||||
description: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the form is still empty enough that saying what is missing
|
||||
/// would be nagging rather than helping — the same restraint the settings
|
||||
/// sheet shows through `ForwardRuleForm::is_blank`.
|
||||
pub(crate) fn is_blank(&self) -> bool {
|
||||
if !self.advanced {
|
||||
return self.target_port.trim().is_empty();
|
||||
}
|
||||
[
|
||||
&self.bind_host,
|
||||
&self.bind_port,
|
||||
@@ -210,73 +240,7 @@ impl Tty7App {
|
||||
Some(bar.into_any_element())
|
||||
}
|
||||
|
||||
pub(crate) fn forwards_section(
|
||||
&self,
|
||||
pane_id: Option<u64>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
let pane_id = pane_id?;
|
||||
let open = self.loopback_panel.form_pane_id == Some(pane_id);
|
||||
// The section's own affordance, and the same 24px chrome tile the Info
|
||||
// tab's cwd actions use. It used to be built by hand — a 32px tile
|
||||
// forced down to 24 and then set `.xsmall()`, which quietly overrode
|
||||
// the 13px the icon asked for with the button size's own 12, so the
|
||||
// glyph never was the size the code claimed. `chrome_tile_sized`
|
||||
// derives it instead: `TILE_GLYPH_SM / BUTTON_ICON_SCALE` of the button
|
||||
// size, the same pair every other 24px tile in the panel is on.
|
||||
let add = crate::ui::tab_strip::chrome_tile_sized(
|
||||
Button::new(("ssh-forward-add-toggle", pane_id))
|
||||
.icon(Icon::empty().path("icons/plus.svg")),
|
||||
TILE_SIZE_SM,
|
||||
TILE_GLYPH_SM,
|
||||
open,
|
||||
cx,
|
||||
)
|
||||
.rounded_md()
|
||||
.tooltip(if open {
|
||||
t(L10nKey::Cancel)
|
||||
} else {
|
||||
t(L10nKey::ForwardTooltipAdd)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.toggle_managed_forward_form(pane_id, window, cx)
|
||||
}))
|
||||
.into_any_element();
|
||||
|
||||
let managed: Vec<ManagedForward> = self
|
||||
.loopback_panel
|
||||
.managed
|
||||
.iter()
|
||||
.filter(|m| m.pane_id == pane_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.));
|
||||
for forward in &managed {
|
||||
list = list.child(self.forward_row(forward, &mono, cx));
|
||||
}
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.child(self.panel_subtitle(t(L10nKey::ForwardPanelTitle), true, Some(add), cx))
|
||||
.when(managed.is_empty() && !open, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.px(px(CONTENT_INSET))
|
||||
.py(px(2.))
|
||||
.text_size(rems(TEXT))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::ui::i18n::t(crate::ui::i18n::L10nKey::None)),
|
||||
)
|
||||
})
|
||||
.when(!managed.is_empty(), |this| this.child(list))
|
||||
.when(open, |this| this.child(self.forward_form(pane_id, cx)))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn forward_row(
|
||||
pub(crate) fn forward_row(
|
||||
&self,
|
||||
forward: &ManagedForward,
|
||||
mono: &gpui::SharedString,
|
||||
@@ -389,19 +353,27 @@ impl Tty7App {
|
||||
)
|
||||
}
|
||||
|
||||
fn forward_form(&self, pane_id: u64, cx: &mut Context<Self>) -> Div {
|
||||
/// The form that adds a forward.
|
||||
///
|
||||
/// Short by default — one number, because "bring the remote's :3000 over
|
||||
/// here" is what nearly every hand-built forward is, and collecting one
|
||||
/// number through five fields is what made this read like paperwork. The
|
||||
/// full `ssh -L` grammar is one disclosure away for the rest.
|
||||
pub(crate) fn forward_form(&self, pane_id: u64, cx: &mut Context<Self>) -> Div {
|
||||
let theme = cx.theme();
|
||||
let muted = theme.muted_foreground;
|
||||
let danger = theme.danger;
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let kind = self.loopback_panel.mf_kind;
|
||||
let editing = self.loopback_panel.mf_editing.is_some();
|
||||
let advanced = self.loopback_panel.mf_advanced;
|
||||
let fields = self.managed_forward_fields(cx);
|
||||
// The form used to accept a click on Add and then do nothing at all
|
||||
// when the fields did not make a rule. Now Add is only live when there
|
||||
// is something to add, and the line below the form says what is still
|
||||
// missing — but not while the form has barely been touched.
|
||||
let complete = fields.collect().is_some();
|
||||
let rule = fields.collect();
|
||||
let complete = rule.is_some();
|
||||
let incomplete = !complete && !fields.is_blank();
|
||||
let selected = match kind {
|
||||
SshForwardKind::Local => 0,
|
||||
@@ -445,49 +417,87 @@ impl Tty7App {
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(self.segmented_on(
|
||||
sf,
|
||||
"ssh-managed-forward-kind",
|
||||
&[
|
||||
t(L10nKey::ForwardLocal),
|
||||
t(L10nKey::ForwardRemote),
|
||||
t(L10nKey::ForwardDynamic),
|
||||
],
|
||||
selected,
|
||||
cx,
|
||||
move |this, ix, _window, cx| {
|
||||
let kind = match ix {
|
||||
1 => SshForwardKind::Remote,
|
||||
2 => SshForwardKind::Dynamic,
|
||||
_ => SshForwardKind::Local,
|
||||
};
|
||||
this.set_managed_forward_kind(kind, cx);
|
||||
},
|
||||
))
|
||||
.child(pair(
|
||||
t(L10nKey::ForwardBindLabel),
|
||||
&self.loopback_panel.mf_bind_host,
|
||||
&self.loopback_panel.mf_bind_port,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.opacity(if needs_target { 1.0 } else { NO_TARGET_FADE })
|
||||
.child(pair(
|
||||
if needs_target {
|
||||
t(L10nKey::ForwardToLabel)
|
||||
} else {
|
||||
t(L10nKey::ForwardSocksLabel)
|
||||
},
|
||||
&self.loopback_panel.mf_target_host,
|
||||
&self.loopback_panel.mf_target_port,
|
||||
)),
|
||||
)
|
||||
.child(Input::new(&self.loopback_panel.mf_description).xsmall())
|
||||
.when(!advanced, |form| {
|
||||
form.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap(px(6.))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(rems(META))
|
||||
.text_color(muted)
|
||||
.child(t(L10nKey::ForwardPortLabel)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(64.))
|
||||
.child(Input::new(&self.loopback_panel.mf_target_port).xsmall()),
|
||||
)
|
||||
// Where the port will come out, said before the click
|
||||
// rather than after it. The number is the same one on
|
||||
// both ends unless it is taken here, which is the case
|
||||
// the row itself reports once the forward exists.
|
||||
.children(rule.as_ref().map(|r| {
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_size(rems(META))
|
||||
.text_color(muted)
|
||||
.child(t_fmt(
|
||||
L10nKey::ForwardPortHere,
|
||||
&[("port", &r.bind_port.to_string())],
|
||||
))
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when(advanced, |form| {
|
||||
form.child(self.segmented_on(
|
||||
sf,
|
||||
"ssh-managed-forward-kind",
|
||||
&[
|
||||
t(L10nKey::ForwardLocal),
|
||||
t(L10nKey::ForwardRemote),
|
||||
t(L10nKey::ForwardDynamic),
|
||||
],
|
||||
selected,
|
||||
cx,
|
||||
move |this, ix, _window, cx| {
|
||||
let kind = match ix {
|
||||
1 => SshForwardKind::Remote,
|
||||
2 => SshForwardKind::Dynamic,
|
||||
_ => SshForwardKind::Local,
|
||||
};
|
||||
this.set_managed_forward_kind(kind, cx);
|
||||
},
|
||||
))
|
||||
.child(pair(
|
||||
t(L10nKey::ForwardBindLabel),
|
||||
&self.loopback_panel.mf_bind_host,
|
||||
&self.loopback_panel.mf_bind_port,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.opacity(if needs_target { 1.0 } else { NO_TARGET_FADE })
|
||||
.child(pair(
|
||||
if needs_target {
|
||||
t(L10nKey::ForwardToLabel)
|
||||
} else {
|
||||
t(L10nKey::ForwardSocksLabel)
|
||||
},
|
||||
&self.loopback_panel.mf_target_host,
|
||||
&self.loopback_panel.mf_target_port,
|
||||
)),
|
||||
)
|
||||
.child(Input::new(&self.loopback_panel.mf_description).xsmall())
|
||||
})
|
||||
.when(incomplete, |form| {
|
||||
form.child(div().text_size(rems(META)).text_color(danger).child(
|
||||
match needs_target {
|
||||
true => t(L10nKey::SettingsFwdNeedsBoth),
|
||||
false => t(L10nKey::SettingsFwdNeedsListen),
|
||||
match (advanced, needs_target) {
|
||||
(false, _) => t(L10nKey::ForwardNeedsPort),
|
||||
(true, true) => t(L10nKey::SettingsFwdNeedsBoth),
|
||||
(true, false) => t(L10nKey::SettingsFwdNeedsListen),
|
||||
},
|
||||
))
|
||||
})
|
||||
@@ -496,31 +506,54 @@ impl Tty7App {
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap(px(4.))
|
||||
.pt(px(1.))
|
||||
// An edit is always shown whole — the short form cannot
|
||||
// spell what an existing rule may contain — so there is
|
||||
// nothing to disclose and no toggle to offer.
|
||||
.child(div().when(!editing, |slot| {
|
||||
slot.child(
|
||||
Button::new(("ssh-managed-forward-advanced", pane_id))
|
||||
.label(if advanced {
|
||||
t(L10nKey::ForwardSimpleToggle)
|
||||
} else {
|
||||
t(L10nKey::ForwardAdvancedToggle)
|
||||
})
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
this.toggle_managed_forward_advanced(cx)
|
||||
})),
|
||||
)
|
||||
}))
|
||||
.child(
|
||||
Button::new(("ssh-managed-forward-cancel", pane_id))
|
||||
.label(t(L10nKey::Cancel))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.close_managed_forward_form(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("ssh-managed-forward-add", pane_id))
|
||||
.label(if editing {
|
||||
t(L10nKey::Save)
|
||||
} else {
|
||||
t(L10nKey::ForwardAdd)
|
||||
})
|
||||
.primary()
|
||||
.xsmall()
|
||||
.disabled(!complete)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add_managed_forward(pane_id, window, cx)
|
||||
})),
|
||||
h_flex()
|
||||
.gap(px(4.))
|
||||
.child(
|
||||
Button::new(("ssh-managed-forward-cancel", pane_id))
|
||||
.label(t(L10nKey::Cancel))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.close_managed_forward_form(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new(("ssh-managed-forward-add", pane_id))
|
||||
.label(if editing {
|
||||
t(L10nKey::Save)
|
||||
} else {
|
||||
t(L10nKey::ForwardAdd)
|
||||
})
|
||||
.primary()
|
||||
.xsmall()
|
||||
.disabled(!complete)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.add_managed_forward(pane_id, window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -532,6 +565,7 @@ mod tests {
|
||||
|
||||
fn fields(kind: SshForwardKind, bind_port: &str, host: &str, port: &str) -> ForwardFields {
|
||||
ForwardFields {
|
||||
advanced: true,
|
||||
kind,
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: bind_port.to_string(),
|
||||
@@ -606,6 +640,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The short form: one number, and the rule it makes reaches the far
|
||||
/// side's own loopback and comes out here under the same number.
|
||||
#[test]
|
||||
fn one_number_is_a_whole_rule_in_the_short_form() {
|
||||
let mut form = fields(SshForwardKind::Local, "", "", "3000");
|
||||
form.advanced = false;
|
||||
// Whatever a trip through the advanced form left behind is not part of
|
||||
// what the short form was asked for.
|
||||
form.bind_host = "0.0.0.0".to_string();
|
||||
form.description = "left over".to_string();
|
||||
let rule = form.collect().expect("a port is enough");
|
||||
assert_eq!(rule.kind, SshForwardKind::Local);
|
||||
assert_eq!(rule.bind_host, "127.0.0.1");
|
||||
assert_eq!(rule.bind_port, 3000, "the address has to be predictable");
|
||||
assert_eq!(rule.target_host, "localhost");
|
||||
assert_eq!(rule.target_port, 3000);
|
||||
assert_eq!(rule.description, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_short_form_is_blank_until_the_port_is_typed() {
|
||||
let mut form = fields(SshForwardKind::Local, "8080", "10.0.0.5", "");
|
||||
form.advanced = false;
|
||||
assert!(
|
||||
form.is_blank(),
|
||||
"fields the short form does not show cannot make it dirty"
|
||||
);
|
||||
assert!(form.collect().is_none());
|
||||
form.target_port = "http".to_string();
|
||||
assert!(
|
||||
!form.is_blank(),
|
||||
"a typed port that is not one still counts"
|
||||
);
|
||||
assert!(form.collect().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_socks_proxy_needs_nothing_but_a_port_to_listen_on() {
|
||||
let rule = fields(SshForwardKind::Dynamic, "1080", "", "")
|
||||
|
||||
+10
-4
@@ -450,9 +450,9 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::SettingsDetectUrlsDesc => {
|
||||
"Underline links on hover and open them on {modifier}-click."
|
||||
}
|
||||
L10nKey::ForwardSshLoopbackLinks => "Forward SSH loopback links",
|
||||
L10nKey::ForwardSshLoopbackLinks => "Forward remote ports",
|
||||
L10nKey::SettingsForwardSshLoopbackLinksDesc => {
|
||||
"When a pane is in SSH, open localhost links through a temporary port forward."
|
||||
"Over SSH, forward the ports a pane starts serving and open its localhost links here."
|
||||
}
|
||||
L10nKey::SettingsOpenFilesInternal => "Built-in editor",
|
||||
L10nKey::SettingsOpenFilesSystem => "Default app",
|
||||
@@ -796,7 +796,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::SettingsSearchFontLigaturesKeywords => "typography glyph fira",
|
||||
L10nKey::SettingsSearchFontSizeKeywords => "typography text bigger smaller zoom",
|
||||
L10nKey::SettingsSearchForwardSshLoopbackLinksKeywords => {
|
||||
"ssh remote port tunnel localhost forward links"
|
||||
"ssh remote port tunnel localhost forward links ports autoforward detect"
|
||||
}
|
||||
L10nKey::SettingsSearchGrokBuildKeywords => {
|
||||
"agent integration hooks install xai grok build"
|
||||
@@ -954,7 +954,6 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
"Could not upload the pasted image to {host}: {error}"
|
||||
}
|
||||
L10nKey::LinkFileOpenFailed => "Could not open {path}: {error}",
|
||||
L10nKey::ForwardPanelTitle => "Forwards",
|
||||
L10nKey::ForwardDisconnected => "Disconnected",
|
||||
L10nKey::ForwardDisconnectedFrom => "Disconnected from {host}",
|
||||
L10nKey::SshEditProfile => "Edit connection…",
|
||||
@@ -967,6 +966,11 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::ForwardToLabel => "to",
|
||||
L10nKey::ForwardSocksLabel => "SOCKS",
|
||||
L10nKey::ForwardAdd => "Add",
|
||||
L10nKey::ForwardPortLabel => "Remote port",
|
||||
L10nKey::ForwardPortHere => "opens at localhost:{port}",
|
||||
L10nKey::ForwardNeedsPort => "A port is a number from 1 to 65535.",
|
||||
L10nKey::ForwardAdvancedToggle => "Advanced",
|
||||
L10nKey::ForwardSimpleToggle => "Simple",
|
||||
L10nKey::ForwardRequestFailed => "Could not reach the session — nothing changed.",
|
||||
L10nKey::FileTreePlaceholderFileName => "file name",
|
||||
L10nKey::FileTreePlaceholderFolderName => "folder name",
|
||||
@@ -1057,6 +1061,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
}
|
||||
L10nKey::PanelProcessesSubtitle => "Processes",
|
||||
L10nKey::PanelPortsSubtitle => "Ports",
|
||||
L10nKey::PanelPortsUnsupported => "That machine's tty7-server is too old to list ports.",
|
||||
L10nKey::PortAutoForwarded => "Remote :{port} is now http://localhost:{local}",
|
||||
L10nKey::PanelCwd => "cwd",
|
||||
L10nKey::PanelShell => "shell",
|
||||
L10nKey::PanelSsh => "ssh",
|
||||
|
||||
+10
-4
@@ -461,9 +461,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsDetectUrlsDesc => {
|
||||
"ホバーでリンクに下線を表示し、{modifier}+クリックで開く"
|
||||
}
|
||||
L10nKey::ForwardSshLoopbackLinks => "SSH ループバックリンクを転送",
|
||||
L10nKey::ForwardSshLoopbackLinks => "リモートポートを転送",
|
||||
L10nKey::SettingsForwardSshLoopbackLinksDesc => {
|
||||
"ペインが SSH 接続中の場合、一時的なポートフォワード経由で localhost リンクを開く"
|
||||
"SSH 接続中、ペインが待ち受けを始めたポートを自動転送し、localhost リンクをこの端末で開く"
|
||||
}
|
||||
L10nKey::SettingsOpenFilesInternal => "内蔵エディタ",
|
||||
L10nKey::SettingsOpenFilesSystem => "デフォルトアプリ",
|
||||
@@ -831,7 +831,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
"タイポグラフィ 文字 拡大 縮小 ズーム font size typography text bigger smaller zoom"
|
||||
}
|
||||
L10nKey::SettingsSearchForwardSshLoopbackLinksKeywords => {
|
||||
"ssh リモート ポート トンネル localhost フォワード リンク forward ssh loopback links tunnel"
|
||||
"ssh リモート ポート トンネル localhost フォワード リンク 自動転送 forward ssh loopback links tunnel ports"
|
||||
}
|
||||
L10nKey::SettingsSearchGrokBuildKeywords => {
|
||||
"エージェント 統合 フック インストール xai grok build agent integration hooks install"
|
||||
@@ -1015,7 +1015,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
"貼り付けた画像を {host} にアップロードできませんでした: {error}"
|
||||
}
|
||||
L10nKey::LinkFileOpenFailed => "{path} を開けませんでした: {error}",
|
||||
L10nKey::ForwardPanelTitle => "ポートフォワード",
|
||||
L10nKey::ForwardDisconnected => "切断済み",
|
||||
L10nKey::ForwardDisconnectedFrom => "{host} から切断されました",
|
||||
L10nKey::SshEditProfile => "接続を編集…",
|
||||
@@ -1028,6 +1027,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::ForwardToLabel => "転送先",
|
||||
L10nKey::ForwardSocksLabel => "SOCKS",
|
||||
L10nKey::ForwardAdd => "追加",
|
||||
L10nKey::ForwardPortLabel => "リモートポート",
|
||||
L10nKey::ForwardPortHere => "localhost:{port} で開きます",
|
||||
L10nKey::ForwardNeedsPort => "ポートは 1 から 65535 までの数字です。",
|
||||
L10nKey::ForwardAdvancedToggle => "詳細",
|
||||
L10nKey::ForwardSimpleToggle => "シンプル",
|
||||
L10nKey::ForwardRequestFailed => "セッションに届きませんでした。何も変更していません",
|
||||
L10nKey::FileTreePlaceholderFileName => "ファイル名",
|
||||
L10nKey::FileTreePlaceholderFolderName => "フォルダ名",
|
||||
@@ -1121,6 +1125,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
}
|
||||
L10nKey::PanelProcessesSubtitle => "プロセス",
|
||||
L10nKey::PanelPortsSubtitle => "ポート",
|
||||
L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。",
|
||||
L10nKey::PortAutoForwarded => "リモートの :{port} は http://localhost:{local} で開けます",
|
||||
L10nKey::PanelCwd => "作業ディレクトリ",
|
||||
L10nKey::PanelShell => "シェル",
|
||||
L10nKey::PanelSsh => "ssh",
|
||||
|
||||
+7
-1
@@ -692,7 +692,6 @@ l10n_keys! {
|
||||
SftpTransferListFailed,
|
||||
SftpImagePasteUploadFailed,
|
||||
LinkFileOpenFailed,
|
||||
ForwardPanelTitle,
|
||||
ForwardDisconnected,
|
||||
ForwardDisconnectedFrom,
|
||||
SshEditProfile,
|
||||
@@ -705,6 +704,11 @@ l10n_keys! {
|
||||
ForwardToLabel,
|
||||
ForwardSocksLabel,
|
||||
ForwardAdd,
|
||||
ForwardPortLabel,
|
||||
ForwardPortHere,
|
||||
ForwardNeedsPort,
|
||||
ForwardAdvancedToggle,
|
||||
ForwardSimpleToggle,
|
||||
ForwardRequestFailed,
|
||||
FileTreePlaceholderFileName,
|
||||
FileTreePlaceholderFolderName,
|
||||
@@ -779,6 +783,8 @@ l10n_keys! {
|
||||
PanelTurnNoScrollback,
|
||||
PanelProcessesSubtitle,
|
||||
PanelPortsSubtitle,
|
||||
PanelPortsUnsupported,
|
||||
PortAutoForwarded,
|
||||
PanelCwd,
|
||||
PanelShell,
|
||||
PanelSsh,
|
||||
|
||||
+10
-4
@@ -396,9 +396,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SettingsLinks => "链接",
|
||||
L10nKey::DetectUrls => "检测 URL",
|
||||
L10nKey::SettingsDetectUrlsDesc => "悬停时给链接加下划线,通过 {modifier}+点击 打开。",
|
||||
L10nKey::ForwardSshLoopbackLinks => "转发 SSH 回环链接",
|
||||
L10nKey::ForwardSshLoopbackLinks => "转发远程端口",
|
||||
L10nKey::SettingsForwardSshLoopbackLinksDesc => {
|
||||
"当窗格处于 SSH 中时,通过临时端口转发打开 localhost 链接。"
|
||||
"SSH 会话里,自动转发窗格开始监听的端口,并在本机打开它的 localhost 链接。"
|
||||
}
|
||||
L10nKey::SettingsOpenFilesInternal => "内置编辑器",
|
||||
L10nKey::SettingsOpenFilesSystem => "默认应用",
|
||||
@@ -738,7 +738,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
"字号 字体大小 文字 放大 缩小 typography font size bigger smaller zoom"
|
||||
}
|
||||
L10nKey::SettingsSearchForwardSshLoopbackLinksKeywords => {
|
||||
"SSH回环链接 端口转发 隧道 localhost 转发 forward ssh loopback links tunnel"
|
||||
"SSH回环链接 端口转发 隧道 localhost 转发 自动转发 端口检测 forward ssh loopback links tunnel ports"
|
||||
}
|
||||
L10nKey::SettingsSearchGrokBuildKeywords => {
|
||||
"Grok Build agent 集成 hook 安装 xai grok build agent integration hooks install"
|
||||
@@ -920,7 +920,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::SftpTransferListFailed => "无法获取传输状态:{error}",
|
||||
L10nKey::SftpImagePasteUploadFailed => "无法将粘贴的图片上传到 {host}:{error}",
|
||||
L10nKey::LinkFileOpenFailed => "无法打开 {path}:{error}",
|
||||
L10nKey::ForwardPanelTitle => "端口转发",
|
||||
L10nKey::ForwardDisconnected => "已断开",
|
||||
L10nKey::ForwardDisconnectedFrom => "与 {host} 的连接已断开",
|
||||
L10nKey::SshEditProfile => "编辑连接…",
|
||||
@@ -933,6 +932,11 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::ForwardToLabel => "到",
|
||||
L10nKey::ForwardSocksLabel => "SOCKS",
|
||||
L10nKey::ForwardAdd => "添加",
|
||||
L10nKey::ForwardPortLabel => "远程端口",
|
||||
L10nKey::ForwardPortHere => "在 localhost:{port} 打开",
|
||||
L10nKey::ForwardNeedsPort => "端口是 1 到 65535 之间的数字。",
|
||||
L10nKey::ForwardAdvancedToggle => "高级",
|
||||
L10nKey::ForwardSimpleToggle => "简单",
|
||||
L10nKey::ForwardRequestFailed => "联系不上这个会话——什么都没有改动。",
|
||||
L10nKey::FileTreePlaceholderFileName => "文件名",
|
||||
L10nKey::FileTreePlaceholderFolderName => "文件夹名",
|
||||
@@ -1010,6 +1014,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::PanelTurnNoScrollback => "这一轮画在 alt screen 上,scrollback 里没有留下它。",
|
||||
L10nKey::PanelProcessesSubtitle => "进程",
|
||||
L10nKey::PanelPortsSubtitle => "端口",
|
||||
L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。",
|
||||
L10nKey::PortAutoForwarded => "远程 :{port} 现在是 http://localhost:{local}",
|
||||
L10nKey::PanelCwd => "工作目录",
|
||||
L10nKey::PanelShell => "shell",
|
||||
L10nKey::PanelSsh => "ssh",
|
||||
|
||||
+506
-60
@@ -2,17 +2,18 @@ use gpui::{AnyElement, Context, Window, div, prelude::*, px, rems};
|
||||
use gpui_component::button::Button;
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex,
|
||||
ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, WindowExt as _,
|
||||
h_flex, v_flex,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::core::config::{Config, RightPanelTab};
|
||||
use crate::daemon::protocol::PaneProcs;
|
||||
use crate::daemon::protocol::{ManagedForward, PaneProcs};
|
||||
use crate::ui::app::{
|
||||
CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App, tile_trailing_inset,
|
||||
tile_trailing_inset_sm,
|
||||
CONTENT_INSET, TILE_GLYPH_SM, TILE_GLYPH_XS, TILE_SIZE_SM, TILE_SIZE_XS, Tty7App,
|
||||
tile_trailing_inset, tile_trailing_inset_sm,
|
||||
};
|
||||
use crate::ui::i18n::{L10nKey, t};
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
use crate::ui::scrollbar::with_vertical_scrollbar;
|
||||
|
||||
pub(crate) const MIN_WIDTH: f32 = 216.;
|
||||
@@ -90,6 +91,17 @@ pub(crate) const ROW_GLYPH: f32 = crate::ui::app::TILE_GLYPH;
|
||||
/// 6px under Info is a panel whose rows visibly do not belong to each other.
|
||||
pub(crate) const ROW_INSET: f32 = 4.;
|
||||
|
||||
/// Whether this forward is the one that reaches `port` on the far side.
|
||||
///
|
||||
/// Local forwards only, and only those aimed at the far host's own loopback:
|
||||
/// a forward to some third machine happens to carry the same number, and
|
||||
/// pairing it with the port row would claim it leads somewhere it does not.
|
||||
pub(crate) fn forwards_port(m: &ManagedForward, port: u16) -> bool {
|
||||
m.kind == crate::daemon::protocol::SshForwardKind::Local
|
||||
&& m.target_port == port
|
||||
&& crate::daemon::protocol::PortEntry::reaches_loopback(&m.target_host)
|
||||
}
|
||||
|
||||
/// The strip the row and group action buttons live in, revealed by hovering
|
||||
/// `row`.
|
||||
///
|
||||
@@ -147,6 +159,31 @@ pub(crate) struct RightPanelState {
|
||||
pub(crate) procs_loading: bool,
|
||||
pub(crate) procs_gen: u64,
|
||||
pub(crate) procs_forwards: Option<crate::ui::app::ForwardRoute>,
|
||||
/// Who to ask about `procs_pane` — `None` means this machine's daemon.
|
||||
pub(crate) procs_host: Option<crate::ui::host_ops::SharedHost>,
|
||||
/// Whether the host that owns this pane cannot describe its processes at
|
||||
/// all: an older `tty7-server` on the far end, which does not know the
|
||||
/// request. Kept apart from an empty list, because "nothing is listening"
|
||||
/// and "nobody could tell us" are different sentences and the panel has to
|
||||
/// say which one it means.
|
||||
pub(crate) procs_unsupported: bool,
|
||||
/// How `procs_pane`'s loopback ports can be reached from this machine.
|
||||
/// Read by the Ports list to decide what a click on a port does, and by
|
||||
/// the watch to decide whether it has to keep looking with the panel shut.
|
||||
pub(crate) port_route: crate::terminal::view::PortRoute,
|
||||
/// The remote ports already forwarded unasked, per set of forwards.
|
||||
///
|
||||
/// Kept so that a forward the user then deletes is not immediately rebuilt
|
||||
/// by the next poll — the automatic offer is made once per port, and after
|
||||
/// that the port is theirs to forward or not.
|
||||
///
|
||||
/// Keyed by owner rather than held for the pane in front, because a
|
||||
/// workspace's forwards are shared by all of its panes: switching tabs
|
||||
/// would otherwise re-announce every port the workspace had already
|
||||
/// forwarded, and switching back would re-offer what was just dismissed.
|
||||
/// A few `u16` per connection is not worth reclaiming.
|
||||
pub(crate) auto_forwarded:
|
||||
std::collections::HashMap<crate::ui::app::ForwardOwnerKey, std::collections::HashSet<u16>>,
|
||||
pub(crate) scroll: gpui::ScrollHandle,
|
||||
pub(crate) tree_scroll: gpui::ScrollHandle,
|
||||
/// A path the tree should scroll onto, and how many more renders it may
|
||||
@@ -163,8 +200,35 @@ pub(crate) struct RightPanelState {
|
||||
/// a moment.
|
||||
pub(crate) const TREE_REVEAL_RENDERS: u8 = 60;
|
||||
|
||||
/// How often the process and port list is re-read while it is on screen —
|
||||
/// close enough that a process appearing feels immediate.
|
||||
const PROCS_POLL: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||
|
||||
/// How often it is re-read with the panel shut, where nobody is watching the
|
||||
/// list and the only question is whether a new port has appeared. A couple of
|
||||
/// extra seconds nobody can feel, against a query that crosses the network on
|
||||
/// every remote pane.
|
||||
const PORT_WATCH_POLL: std::time::Duration = std::time::Duration::from_millis(5000);
|
||||
|
||||
/// How many newly-seen ports one poll may forward. A dev server brings up one
|
||||
/// or two; a number this side of a dozen is a process opening listeners in a
|
||||
/// loop, and forwarding all of them helps nobody.
|
||||
const AUTO_FORWARD_BURST: usize = 4;
|
||||
|
||||
/// What the Ports and Forwards sections are describing.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PaneForwardCtx {
|
||||
pub(crate) pane_id: u64,
|
||||
/// `Some` when the pane has somewhere to hold managed forwards.
|
||||
pub(crate) route: Option<crate::ui::app::ForwardRoute>,
|
||||
pub(crate) port_route: crate::terminal::view::PortRoute,
|
||||
/// The host that owns this pane's processes, when that is not this
|
||||
/// machine. A remote workspace's panes live in the peer's registry, so the
|
||||
/// local daemon — which is what `query_procs` asks — has never heard of
|
||||
/// them and answers with an empty list.
|
||||
pub(crate) host: Option<crate::ui::host_ops::SharedHost>,
|
||||
}
|
||||
|
||||
/// What a session row draws in its value column.
|
||||
///
|
||||
/// Every row used to be a `(&str, String)` pair rendered identically, and the
|
||||
@@ -645,14 +709,10 @@ impl Tty7App {
|
||||
fn render_panel_info(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let title = self.panel_title(t(L10nKey::PanelInfoTitle), None, None, window, cx);
|
||||
let mut rows: Vec<InfoRow> = Vec::new();
|
||||
// Which pane the sections below describe, and what can be done with
|
||||
// its ports. Worked out once, by the same call the watch uses.
|
||||
let ctx = self.pane_forward_ctx(window, cx);
|
||||
let mut pane_id: Option<u64> = None;
|
||||
let mut forwards_pane: Option<u64> = None;
|
||||
// Whether the ports below are this machine's. They are listed by the
|
||||
// daemon that owns the pane, so what decides it is which machine that
|
||||
// daemon runs on — not whether the shell has since ssh'd somewhere,
|
||||
// which would hide the browser tile on a `ssh -L` pane whose forwarded
|
||||
// listener is on this machine and reachable.
|
||||
let mut local_pane = false;
|
||||
// Where the `changes` row's counts lead. Same source as the sidebar's,
|
||||
// and gated on the same setting, so turning the preview off turns it
|
||||
// off in both places rather than in one of them.
|
||||
@@ -666,7 +726,6 @@ impl Tty7App {
|
||||
if let Some(leaf) = tab.detail_pane(window, cx) {
|
||||
let view = leaf.read(cx);
|
||||
pane_id = Some(view.pane_id);
|
||||
local_pane = view.host_id().is_local();
|
||||
diff_target = crate::ui::tab_sidebar::diff_click_cwd(
|
||||
cx.global::<Config>(),
|
||||
view.git_status_cwd()
|
||||
@@ -702,16 +761,6 @@ impl Tty7App {
|
||||
if let Some(ssh) = view.ssh_spec() {
|
||||
rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable());
|
||||
}
|
||||
let connected_ssh = view
|
||||
.remote_context()
|
||||
.is_some_and(|c| c.kind == crate::daemon::protocol::RemoteKind::NativeSsh)
|
||||
&& matches!(
|
||||
view.ssh_phase(),
|
||||
Some(crate::daemon::protocol::SshPhase::Connected)
|
||||
);
|
||||
if connected_ssh || view.workspace().is_some() {
|
||||
forwards_pane = Some(view.pane_id);
|
||||
}
|
||||
git = view.git_status(cx);
|
||||
detail_pane = Some(leaf);
|
||||
}
|
||||
@@ -751,9 +800,6 @@ impl Tty7App {
|
||||
);
|
||||
}
|
||||
|
||||
let route = forwards_pane.map(|id| self.forward_route(id, cx));
|
||||
self.sync_procs(pane_id, route, cx);
|
||||
|
||||
let label_w = info_label_column(&rows, window, cx);
|
||||
// Rows pad themselves back out to `CONTENT_INSET`, so their hover fill
|
||||
// bleeds past the text on both sides — the geometry the Source Control
|
||||
@@ -768,8 +814,7 @@ impl Tty7App {
|
||||
.child(list)
|
||||
.children(self.turns_section(detail_pane.as_ref(), cx))
|
||||
.children(self.procs_section(pane_id, cx))
|
||||
.children(self.ports_section(pane_id, local_pane, cx))
|
||||
.children(self.forwards_section(forwards_pane, cx))
|
||||
.children(self.ports_section(ctx.as_ref(), cx))
|
||||
.into_any_element();
|
||||
self.panel_scroll(inner, title)
|
||||
}
|
||||
@@ -1183,34 +1228,59 @@ impl Tty7App {
|
||||
)
|
||||
}
|
||||
|
||||
/// The listening ports of the pane's processes.
|
||||
/// The ports this pane is serving, and what it takes to reach them.
|
||||
///
|
||||
/// `local` is whether the daemon that listed these ports is this machine's,
|
||||
/// and it is what decides whether the browser tile appears: a port on a
|
||||
/// remote host is not this machine's port, and opening it here is not a
|
||||
/// near miss, it is a different service. It is deliberately about the
|
||||
/// *host* and not about whether the shell has ssh'd somewhere — the ports
|
||||
/// come from the pane's own process tree either way, so a `ssh -L` pane's
|
||||
/// forwarded listener really is on this machine and really does open.
|
||||
/// One list, not two. Ports and forwards used to be separate sections, so
|
||||
/// a remote :3000 and the forward that reaches :3000 sat under different
|
||||
/// headings with nothing saying they were the same thing — and the ports
|
||||
/// half offered no way to build the forward the other half was for. A row
|
||||
/// is a port here, and the forward, when there is one, is where that row
|
||||
/// says it comes out.
|
||||
fn ports_section(
|
||||
&self,
|
||||
pane_id: Option<u64>,
|
||||
local: bool,
|
||||
ctx: Option<&PaneForwardCtx>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
let ports = &self.procs(pane_id)?.ports;
|
||||
if ports.is_empty() {
|
||||
let ctx = ctx?;
|
||||
let pane_id = ctx.pane_id;
|
||||
let ports = self
|
||||
.procs(Some(pane_id))
|
||||
.map(|p| p.ports.clone())
|
||||
.unwrap_or_default();
|
||||
let forwards: Vec<ManagedForward> = self
|
||||
.loopback_panel
|
||||
.managed
|
||||
.iter()
|
||||
.filter(|m| m.pane_id == pane_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let form_open = self.loopback_panel.form_pane_id == Some(pane_id);
|
||||
// A pane that cannot hold a forward and is serving nothing has no
|
||||
// section: the heading alone would be an empty promise.
|
||||
if ports.is_empty() && forwards.is_empty() && ctx.route.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
let openable = ctx.port_route != crate::terminal::view::PortRoute::Blocked;
|
||||
let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(1.));
|
||||
// Which forwards a port row has already accounted for; whatever is
|
||||
// left over gets a row of its own below.
|
||||
let mut paired: Vec<u64> = Vec::new();
|
||||
|
||||
for (i, p) in ports.iter().enumerate() {
|
||||
// "What is this pane serving, and where" is the question the
|
||||
// section answers, and the next thing anyone does with the answer
|
||||
// is go there — so the row hands over an address instead of making
|
||||
// it something to read off the screen and retype.
|
||||
let authority = p.authority();
|
||||
let forward = forwards.iter().find(|m| forwards_port(m, p.port));
|
||||
if let Some(f) = forward {
|
||||
paired.push(f.id);
|
||||
}
|
||||
// What a click and a copy are about: the address that works from
|
||||
// here. Once a forward exists that is the local end of it, not the
|
||||
// far side's own spelling of the port.
|
||||
let here = forward.map(|f| f.bind_port);
|
||||
let authority = here
|
||||
.map(|local| format!("127.0.0.1:{local}"))
|
||||
.unwrap_or_else(|| p.authority());
|
||||
// Keyed by the row, not by the port: `listening_ports` drops a
|
||||
// duplicate only when the port *and* the pid match, so a
|
||||
// pre-forking server — nginx, gunicorn, a node cluster — puts one
|
||||
@@ -1221,9 +1291,10 @@ impl Tty7App {
|
||||
let id = gpui::SharedString::from(format!("panel-port-{}-{}", p.port, p.pid));
|
||||
let mut tiles_wide = 1;
|
||||
let mut actions = action_strip(&id, sf.hover);
|
||||
if local {
|
||||
if openable {
|
||||
tiles_wide += 1;
|
||||
let url = format!("http://{authority}");
|
||||
let port = p.port;
|
||||
let direct = p.authority();
|
||||
actions = actions.child(
|
||||
self.info_tile(
|
||||
("panel-port-open", i),
|
||||
@@ -1231,7 +1302,9 @@ impl Tty7App {
|
||||
t(L10nKey::PanelOpenInBrowser),
|
||||
cx,
|
||||
)
|
||||
.on_click(move |_, _window, cx| cx.open_url(&url)),
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.open_pane_port(port, direct.clone(), cx)
|
||||
})),
|
||||
);
|
||||
}
|
||||
actions = actions.child(
|
||||
@@ -1248,6 +1321,21 @@ impl Tty7App {
|
||||
}
|
||||
}),
|
||||
);
|
||||
if let Some(f) = forward {
|
||||
tiles_wide += 1;
|
||||
let forward_id = f.id;
|
||||
actions = actions.child(
|
||||
self.info_tile(
|
||||
("panel-port-unforward", i),
|
||||
IconName::Close,
|
||||
t(L10nKey::ForwardTooltipRemove),
|
||||
cx,
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.remove_managed_forward(pane_id, forward_id, cx)
|
||||
})),
|
||||
);
|
||||
}
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.id(id.clone())
|
||||
@@ -1279,41 +1367,327 @@ impl Tty7App {
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(p.name.clone()),
|
||||
)
|
||||
// Where the port comes out on this machine. Just the
|
||||
// number: the host is always this machine's loopback, and
|
||||
// spelling it out on every row would bury the one part
|
||||
// that differs.
|
||||
.children(here.map(|local| {
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(rems(META_MONO))
|
||||
.font_family(mono.clone())
|
||||
.text_color(cx.theme().muted_foreground.opacity(0.8))
|
||||
.child(format!("→ :{local}"))
|
||||
}))
|
||||
.child(actions),
|
||||
);
|
||||
}
|
||||
|
||||
// Forwards no port row spoke for: the remote and dynamic ones, and any
|
||||
// local forward pointed somewhere this pane is not itself serving.
|
||||
for forward in forwards.iter().filter(|m| !paired.contains(&m.id)) {
|
||||
list = list.child(self.forward_row(forward, &mono, cx));
|
||||
}
|
||||
|
||||
let add = ctx.route.is_some().then(|| {
|
||||
crate::ui::tab_strip::chrome_tile_sized(
|
||||
Button::new(("ssh-forward-add-toggle", pane_id))
|
||||
.icon(Icon::empty().path("icons/plus.svg")),
|
||||
TILE_SIZE_SM,
|
||||
TILE_GLYPH_SM,
|
||||
form_open,
|
||||
cx,
|
||||
)
|
||||
.rounded_md()
|
||||
.tooltip(if form_open {
|
||||
t(L10nKey::Cancel)
|
||||
} else {
|
||||
t(L10nKey::ForwardTooltipAdd)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.toggle_managed_forward_form(pane_id, window, cx)
|
||||
}))
|
||||
.into_any_element()
|
||||
});
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.child(self.panel_subtitle(t(L10nKey::PanelPortsSubtitle), true, None, cx))
|
||||
.child(self.panel_subtitle(t(L10nKey::PanelPortsSubtitle), true, add, cx))
|
||||
.when(
|
||||
ports.is_empty() && forwards.is_empty() && !form_open,
|
||||
|this| {
|
||||
// "Nothing is listening" and "nobody could tell us"
|
||||
// look identical on screen unless the panel says which
|
||||
// one it means — and the second is a fixable thing:
|
||||
// the far end is running a server too old to answer.
|
||||
let (text, tone) = match self.right_panel.procs_unsupported {
|
||||
true => (
|
||||
t(L10nKey::PanelPortsUnsupported),
|
||||
cx.theme().muted_foreground,
|
||||
),
|
||||
false => (t(L10nKey::None), cx.theme().muted_foreground),
|
||||
};
|
||||
this.child(
|
||||
div()
|
||||
.px(px(CONTENT_INSET))
|
||||
.py(px(2.))
|
||||
.text_size(rems(TEXT))
|
||||
.text_color(tone)
|
||||
.child(text),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(list)
|
||||
.when(form_open, |this| this.child(self.forward_form(pane_id, cx)))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Open one of this pane's ports in a browser, building the forward it
|
||||
/// needs first when it needs one.
|
||||
///
|
||||
/// The forward is the part the user should not have to think about: they
|
||||
/// asked to see :3000, and where :3000 has to be tunnelled to be seen,
|
||||
/// that is this function's problem and not theirs.
|
||||
pub(crate) fn open_pane_port(
|
||||
&mut self,
|
||||
port: u16,
|
||||
direct_authority: String,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
use crate::terminal::view::PortRoute;
|
||||
match self.right_panel.port_route {
|
||||
PortRoute::Blocked => {}
|
||||
PortRoute::Direct => cx.open_url(&format!("http://{direct_authority}")),
|
||||
PortRoute::Forward => {
|
||||
if let Some(f) = self
|
||||
.loopback_panel
|
||||
.managed
|
||||
.iter()
|
||||
.find(|m| forwards_port(m, port))
|
||||
{
|
||||
cx.open_url(&format!("http://127.0.0.1:{}", f.bind_port));
|
||||
return;
|
||||
}
|
||||
let Some(route) = self.right_panel.procs_forwards.clone() else {
|
||||
return;
|
||||
};
|
||||
let owner = route.owner_key();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let built = cx
|
||||
.background_executor()
|
||||
.spawn(async move { route.ensure_loopback("127.0.0.1", port) })
|
||||
.await;
|
||||
let _ = this.update_in(cx, |app, window, cx| match built {
|
||||
Ok(f) => {
|
||||
// Claimed, so the watch does not offer this port a
|
||||
// second time after the user has just opened it.
|
||||
app.right_panel
|
||||
.auto_forwarded
|
||||
.entry(owner)
|
||||
.or_default()
|
||||
.insert(port);
|
||||
cx.open_url(&format!("http://127.0.0.1:{}", f.local_port));
|
||||
if let Some(pane_id) = app.right_panel.procs_pane {
|
||||
app.refresh_managed_forwards(pane_id, cx);
|
||||
}
|
||||
}
|
||||
Err(e) => window.push_notification(
|
||||
t_fmt(
|
||||
L10nKey::LoopbackForwardFailed,
|
||||
&[("port", &port.to_string()), ("error", &e.to_string())],
|
||||
),
|
||||
cx,
|
||||
),
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn procs(&self, pane_id: Option<u64>) -> Option<&PaneProcs> {
|
||||
(pane_id.is_some() && self.right_panel.procs_pane == pane_id)
|
||||
.then_some(self.right_panel.procs.as_ref())?
|
||||
}
|
||||
|
||||
/// What the Ports and Forwards sections are describing: which pane, how
|
||||
/// forward requests about it reach a daemon, and whether the loopback
|
||||
/// ports it is serving can be opened from this machine.
|
||||
///
|
||||
/// One answer for both readers. The panel draws from it and the watch polls
|
||||
/// from it, and when they were each working it out for themselves the panel
|
||||
/// could offer to open a port the watch had already given up on.
|
||||
pub(crate) fn pane_forward_ctx(
|
||||
&self,
|
||||
window: &Window,
|
||||
cx: &gpui::App,
|
||||
) -> Option<PaneForwardCtx> {
|
||||
let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?;
|
||||
let view = leaf.read(cx);
|
||||
// A pane holds managed forwards once it has somewhere to hold them:
|
||||
// a live native-ssh connection, or the workspace's shared one.
|
||||
let connected_ssh = view
|
||||
.remote_context()
|
||||
.is_some_and(|c| c.kind == crate::daemon::protocol::RemoteKind::NativeSsh)
|
||||
&& matches!(
|
||||
view.ssh_phase(),
|
||||
Some(crate::daemon::protocol::SshPhase::Connected)
|
||||
);
|
||||
Some(PaneForwardCtx {
|
||||
pane_id: view.pane_id,
|
||||
route: (connected_ssh || view.workspace().is_some()).then(|| view.forward_route()),
|
||||
port_route: view.port_route(cx),
|
||||
host: (!view.host_id().is_local())
|
||||
.then(|| view.host(cx))
|
||||
.flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Point the port watch at whatever pane is in front, once a frame.
|
||||
///
|
||||
/// Driven from the app's own render rather than the panel's: the watch has
|
||||
/// to run with the panel shut, which is exactly when a new port appearing
|
||||
/// is worth saying something about.
|
||||
pub(crate) fn sync_port_watch(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let ctx = self.pane_forward_ctx(window, cx);
|
||||
self.right_panel.port_route = ctx
|
||||
.as_ref()
|
||||
.map_or(crate::terminal::view::PortRoute::Blocked, |c| c.port_route);
|
||||
let pane_id = ctx.as_ref().map(|c| c.pane_id);
|
||||
let host = ctx.as_ref().and_then(|c| c.host.clone());
|
||||
let route = ctx.and_then(|c| c.route);
|
||||
self.sync_procs(pane_id, route, host, cx);
|
||||
}
|
||||
|
||||
/// Whether the process/port poll should run at all this round.
|
||||
fn procs_wanted(&self) -> bool {
|
||||
(self.right_panel_visible && self.right_panel_tab == RightPanelTab::Info)
|
||||
|| self.watching_ports()
|
||||
}
|
||||
|
||||
/// Whether the poll has to keep going with the panel closed: on a pane
|
||||
/// whose ports need forwarding, noticing a new listener *is* the feature,
|
||||
/// and a shut panel is not a reason to stop looking.
|
||||
fn watching_ports(&self) -> bool {
|
||||
self.right_panel.port_route == crate::terminal::view::PortRoute::Forward
|
||||
&& self.right_panel.procs_forwards.is_some()
|
||||
}
|
||||
|
||||
/// Forward the loopback ports this poll saw for the first time, and say so.
|
||||
///
|
||||
/// Once per port, not once per poll: a forward the user then deletes stays
|
||||
/// deleted. Only ports bound somewhere this machine could reach through the
|
||||
/// tunnel — a listener pinned to one of the far host's own interfaces is a
|
||||
/// different service, and guessing at it would build a forward to nothing.
|
||||
fn auto_forward_ports(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.watching_ports() {
|
||||
return;
|
||||
}
|
||||
let Some(route) = self.right_panel.procs_forwards.clone() else {
|
||||
return;
|
||||
};
|
||||
// Read out before the ledger is touched: the ports are behind the same
|
||||
// borrow the `seen` entry needs.
|
||||
let listening: Vec<u16> = match self.right_panel.procs.as_ref() {
|
||||
Some(procs) => procs
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|p| crate::daemon::protocol::PortEntry::reaches_loopback(&p.addr))
|
||||
.map(|p| p.port)
|
||||
.collect(),
|
||||
None => return,
|
||||
};
|
||||
let owner = route.owner_key();
|
||||
let seen = self.right_panel.auto_forwarded.entry(owner).or_default();
|
||||
let mut fresh: Vec<u16> = Vec::new();
|
||||
for port in listening {
|
||||
if fresh.len() >= AUTO_FORWARD_BURST {
|
||||
break;
|
||||
}
|
||||
// Two rows may name one port — a pre-forking server puts one per
|
||||
// worker on screen — and they want one forward between them.
|
||||
if seen.contains(&port) || fresh.contains(&port) {
|
||||
continue;
|
||||
}
|
||||
fresh.push(port);
|
||||
}
|
||||
if fresh.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Claimed before the request goes out, so the next poll — two seconds
|
||||
// away, and this round trip crosses a network — does not ask again.
|
||||
seen.extend(fresh.iter().copied());
|
||||
cx.spawn(async move |this, cx| {
|
||||
let built = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
fresh
|
||||
.into_iter()
|
||||
.map(|port| {
|
||||
let local = route
|
||||
.ensure_loopback("127.0.0.1", port)
|
||||
.map(|f| f.local_port);
|
||||
(port, local)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.await;
|
||||
let _ = this.update_in(cx, |app, window, cx| {
|
||||
for (port, local) in built {
|
||||
match local {
|
||||
Ok(local) => window.push_notification(
|
||||
t_fmt(
|
||||
L10nKey::PortAutoForwarded,
|
||||
&[("port", &port.to_string()), ("local", &local.to_string())],
|
||||
),
|
||||
cx,
|
||||
),
|
||||
Err(e) => {
|
||||
// Usually a connection that is not up yet. Let the
|
||||
// next poll try again rather than writing the port
|
||||
// off for the life of the pane.
|
||||
log::debug!("could not auto-forward :{port}: {e}");
|
||||
if let Some(seen) = app.right_panel.auto_forwarded.get_mut(&owner) {
|
||||
seen.remove(&port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn sync_procs(
|
||||
&mut self,
|
||||
pane_id: Option<u64>,
|
||||
forwards: Option<crate::ui::app::ForwardRoute>,
|
||||
host: Option<crate::ui::host_ops::SharedHost>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(pane_id) = pane_id else { return };
|
||||
self.right_panel.procs_forwards = forwards.clone();
|
||||
self.right_panel.procs_host = host.clone();
|
||||
if self.right_panel.procs_pane != Some(pane_id) {
|
||||
self.right_panel.procs_pane = Some(pane_id);
|
||||
self.right_panel.procs = None;
|
||||
self.loopback_panel.managed.clear();
|
||||
self.right_panel.procs_gen += 1;
|
||||
self.right_panel.procs_loading = false;
|
||||
self.right_panel.procs_unsupported = false;
|
||||
}
|
||||
if !self.right_panel.procs_loading {
|
||||
// Asked before the first query as well as before every later one. This
|
||||
// used to be reached only from the Info panel's own render, where the
|
||||
// panel being open was implied; driven from the app's render it is not,
|
||||
// and starting a round trip per frame for a pane nobody is watching is
|
||||
// both wasted IPC and, under a test executor, a queue that never
|
||||
// empties.
|
||||
if !self.right_panel.procs_loading && self.procs_wanted() {
|
||||
self.right_panel.procs_loading = true;
|
||||
let generation = self.right_panel.procs_gen;
|
||||
self.spawn_procs_query(pane_id, generation, forwards, cx);
|
||||
self.spawn_procs_query(pane_id, generation, forwards, host, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1322,6 +1696,7 @@ impl Tty7App {
|
||||
pane_id: u64,
|
||||
generation: u64,
|
||||
forwards: Option<crate::ui::app::ForwardRoute>,
|
||||
host: Option<crate::ui::host_ops::SharedHost>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
@@ -1329,7 +1704,19 @@ impl Tty7App {
|
||||
let (procs, managed) = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let procs = crate::terminal::RemoteTerminal::query_procs(pane_id);
|
||||
// A remote workspace's pane runs on the peer, so the peer
|
||||
// is the only one that can walk its process tree; the local
|
||||
// daemon does not have the pane at all and would answer
|
||||
// with an empty list. `None` back from the host means it
|
||||
// could not be asked, which is not the same as an empty
|
||||
// answer — see `Host::pane_procs`.
|
||||
let procs = match &host {
|
||||
Some(host) => {
|
||||
use crate::ui::host_ops::Host as _;
|
||||
host.pane_procs(pane_id)
|
||||
}
|
||||
None => Some(crate::terminal::RemoteTerminal::query_procs(pane_id)),
|
||||
};
|
||||
let managed = route.map(|r| r.list()).unwrap_or_default();
|
||||
(procs, managed)
|
||||
})
|
||||
@@ -1339,31 +1726,47 @@ impl Tty7App {
|
||||
if app.right_panel.procs_gen != generation {
|
||||
return false;
|
||||
}
|
||||
app.right_panel.procs = Some(procs);
|
||||
app.right_panel.procs_unsupported = procs.is_none();
|
||||
// A host that could not answer leaves the last list it did
|
||||
// answer with in place: blanking it on one failed poll
|
||||
// would make the panel flicker on a link that hiccups.
|
||||
if let Some(procs) = procs {
|
||||
app.right_panel.procs = Some(procs);
|
||||
}
|
||||
if forwards.is_some() {
|
||||
app.loopback_panel.managed = managed;
|
||||
}
|
||||
cx.notify();
|
||||
let wanted =
|
||||
app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info;
|
||||
let wanted = app.procs_wanted();
|
||||
if !wanted {
|
||||
app.right_panel.procs_loading = false;
|
||||
}
|
||||
wanted
|
||||
})
|
||||
.unwrap_or(false);
|
||||
// After the list has landed, so the ports it forwards are the ones
|
||||
// this poll actually saw.
|
||||
let _ = this.update(cx, |app, cx| app.auto_forward_ports(cx));
|
||||
if !keep_polling {
|
||||
return;
|
||||
}
|
||||
cx.background_executor().timer(PROCS_POLL).await;
|
||||
let gap = this
|
||||
.read_with(cx, |app, _| {
|
||||
match app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info {
|
||||
true => PROCS_POLL,
|
||||
false => PORT_WATCH_POLL,
|
||||
}
|
||||
})
|
||||
.unwrap_or(PORT_WATCH_POLL);
|
||||
cx.background_executor().timer(gap).await;
|
||||
let _ = this.update(cx, |app, cx| {
|
||||
if app.right_panel.procs_gen != generation {
|
||||
return;
|
||||
}
|
||||
let wanted = app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info;
|
||||
if wanted {
|
||||
if app.procs_wanted() {
|
||||
let forwards = app.right_panel.procs_forwards.clone();
|
||||
app.spawn_procs_query(pane_id, generation, forwards, cx);
|
||||
let host = app.right_panel.procs_host.clone();
|
||||
app.spawn_procs_query(pane_id, generation, forwards, host, cx);
|
||||
} else {
|
||||
app.right_panel.procs_loading = false;
|
||||
}
|
||||
@@ -1501,7 +1904,50 @@ fn turn_is_jumpable(row: Option<i64>, alt_now: bool) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InfoRow, InfoValue, turn_is_jumpable};
|
||||
use super::{InfoRow, InfoValue, forwards_port, turn_is_jumpable};
|
||||
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
|
||||
|
||||
fn forward(kind: SshForwardKind, target_host: &str, target_port: u16) -> ManagedForward {
|
||||
ManagedForward {
|
||||
id: 1,
|
||||
pane_id: 7,
|
||||
kind,
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: 51000,
|
||||
target_host: target_host.to_string(),
|
||||
target_port,
|
||||
description: None,
|
||||
status: ForwardStatus::Listening,
|
||||
}
|
||||
}
|
||||
|
||||
/// A port row and the forward that reaches it are one line, so this is
|
||||
/// what decides whether a forward is *that* row's or a line of its own.
|
||||
#[test]
|
||||
fn a_port_row_claims_only_the_forward_that_reaches_it() {
|
||||
assert!(forwards_port(
|
||||
&forward(SshForwardKind::Local, "localhost", 3000),
|
||||
3000
|
||||
));
|
||||
assert!(
|
||||
forwards_port(&forward(SshForwardKind::Local, "127.0.0.1", 3000), 3000),
|
||||
"the far side's loopback spells itself several ways"
|
||||
);
|
||||
assert!(
|
||||
!forwards_port(&forward(SshForwardKind::Local, "localhost", 3000), 8080),
|
||||
"a different port is a different row"
|
||||
);
|
||||
assert!(
|
||||
!forwards_port(&forward(SshForwardKind::Local, "10.0.0.5", 3000), 3000),
|
||||
"same number, another machine — pairing them would claim it leads \
|
||||
somewhere it does not"
|
||||
);
|
||||
assert!(
|
||||
!forwards_port(&forward(SshForwardKind::Remote, "localhost", 3000), 3000),
|
||||
"a remote forward listens on the far side, so it is not how this \
|
||||
port is reached from here"
|
||||
);
|
||||
}
|
||||
|
||||
fn diff(added: u32, removed: u32, open: bool) -> InfoRow {
|
||||
InfoRow {
|
||||
|
||||
Reference in New Issue
Block a user