sources/Google.Solutions.Terminal/Controls/SshHybridClient.cs (148 lines of code) (raw):

// // Copyright 2024 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // using Google.Solutions.Common.Util; using Google.Solutions.Mvvm.Binding; using Google.Solutions.Mvvm.Controls; using Google.Solutions.Ssh; using System; using System.ComponentModel; using System.Diagnostics; using System.Threading.Tasks; using System.Windows.Forms; namespace Google.Solutions.Terminal.Controls { /// <summary> /// Client that connects a virtual terminal to an SSH shell channel, /// and optionally allow browsing the remote file system using SFTP. /// </summary> public class SshHybridClient : SshShellClient { private readonly SplitContainer container; private readonly SplitterPanel terminalPanel; private readonly SplitterPanel fileBrowserPanel; private IBindingContext? bindingContext; private FileBrowser? fileBrowser; public SshHybridClient() { SuspendLayout(); this.container = new SplitContainer() { Dock = DockStyle.Fill, Orientation = Orientation.Horizontal, SplitterIncrement = 10, SplitterWidth = 6, }; this.Controls.Add(this.container); this.terminalPanel = this.container.Panel1; this.fileBrowserPanel = this.container.Panel2; // // Move terminal into panel1. // this.Controls.Remove(this.Terminal); this.terminalPanel.Controls.Add(this.Terminal); // // Only show terminal by default. // this.container.Panel1Collapsed = true; this.container.Panel2Collapsed = true; this.container.SplitterMoved += OnSplitterMoved; // // Allow user to drop files onto terminal. But instead // of initiating an upload, open the file browser. // this.Terminal.AllowDrop = true; this.Terminal.DragEnter += (_, args) => { // // Open file browser to indicate that that's how // you drag and drop files. // if (FileBrowser.CanPaste(args.Data)) { this.IsFileBrowserVisible = true; } }; ResumeLayout(false); } /// <summary> /// Enable binding. /// </summary> public override void Bind(IBindingContext bindingContext) { this.bindingContext = bindingContext; } //--------------------------------------------------------------------- // Overrides. //--------------------------------------------------------------------- protected override void OnStateChanged() { base.OnStateChanged(); if (!this.CanShowFileBrowser) { // // Hide file browser as soon as we're not in logged-on state // anymore. // this.IsFileBrowserVisible = false; } } //--------------------------------------------------------------------- // Splitter events. //--------------------------------------------------------------------- private void OnSplitterMoved(object sender, SplitterEventArgs e) { if (this.fileBrowserPanel.Height <= this.Height / 10) { // // File browser panel resized to < 10%>, hide entirely. // this.IsFileBrowserVisible = false; } } //--------------------------------------------------------------------- // File browser. //--------------------------------------------------------------------- /// <summary> /// Raised when an SFTP operation related to file browsing failed. /// </summary> public event EventHandler<ExceptionEventArgs>? FileBrowsingFailed; /// <summary> /// Enable file browser. /// </summary> [Browsable(true)] [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] [Category(SshCategory)] public bool EnableFileBrowser { get; set; } = true; /// <summary> /// Check if the control is in a state that permits the /// file browser to be shown. /// </summary> public bool CanShowFileBrowser { // // The browser can only be shown in logged-on state. // get => this.EnableFileBrowser && this.State == ConnectionState.LoggedOn && this.BindingContext != null; } /// <summary> /// Toggle visibility of SFTP file browser. /// </summary> public bool IsFileBrowserVisible { get => !this.container.Panel2Collapsed; set { if (value == this.IsFileBrowserVisible) { // // No change, but activate file browser. This // enables switching from terminal to an already- // opened file browser by using the keyboard. // this.fileBrowser?.Select(); this.fileBrowser?.Focus(); } else if (value) { if (!this.CanShowFileBrowser) { return; } Debug.Assert(this.fileBrowser == null); Precondition.Expect( this.bindingContext != null, "Control must be bound"); // // Show in bottom third. // this.container.SplitterDistance = this.Height * 2 / 3; this.container.Panel2Collapsed = false; _ = OpenFileBrowserAsync(); } else { // // Hide. // this.container.Panel2Collapsed = true; if (this.fileBrowser != null) { // // Dispose the file browser control and its // underlying channel. // this.Controls.Remove(this.fileBrowser); this.fileBrowser.Dispose(); this.fileBrowser = null; } } async Task OpenFileBrowserAsync() { // // Open SFTP channel using the existing connection. // var fsChannel = await this.Connection .OpenFileSystemAsync() .ConfigureAwait(true); // // Bind it to a file browser control. // this.fileBrowser = new FileBrowser() { Dock = DockStyle.Fill, StreamCopyBufferSize = SftpChannel.BufferSize, }; this.fileBrowserPanel.Controls.Add(this.fileBrowser); var fileSystem = new SftpFileSystem(fsChannel); this.fileBrowser.Disposed += (_, args) => fileSystem.Dispose(); // // Propagate browsing events. // this.fileBrowser.FileCopyFailed += (_, args) => this.FileBrowsingFailed?.Invoke(this, args); this.fileBrowser.NavigationFailed += (_, args) => this.FileBrowsingFailed?.Invoke(this, args); this.fileBrowser.Bind( fileSystem, this.bindingContext!); // // Move focus away from terminal to file browser. // This implicily causes the root directory to // be populated. // this.fileBrowser.Select(); this.fileBrowser.Focus(); } } } public void BrowseFiles() { if (!this.CanShowFileBrowser) { return; } this.IsFileBrowserVisible = true; } // TODO: paste icon } }