Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
use crate::keyboard::{remove_custom_keybinding, write_custom_keybinding, UserDefinedKeybinding};
|
||||
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
||||
use enum_iterator::{all, Sequence};
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use warpui::keymap::{BindingId, IsBindingValid};
|
||||
use warpui::platform::OperatingSystem;
|
||||
use warpui::{
|
||||
actions::StandardAction,
|
||||
keymap::{
|
||||
BindingDescription, BindingLens, CustomTag, DescriptionContext, EditableBindingLens,
|
||||
Keystroke, Trigger,
|
||||
},
|
||||
Action,
|
||||
};
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
pub const MAC_MENUS_CONTEXT: DescriptionContext = DescriptionContext::Custom("mac_menus");
|
||||
|
||||
// CustomActions are attached to menu items, and may be attached to Bindings.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Sequence)]
|
||||
#[repr(isize)]
|
||||
pub enum CustomAction {
|
||||
NewTab,
|
||||
NewFile,
|
||||
ShowAboutWarp,
|
||||
ShowSettings,
|
||||
ConfigureKeybindings,
|
||||
ShowAccount,
|
||||
ShowAppearance,
|
||||
ReferAFriend,
|
||||
ViewChangelog,
|
||||
FocusInput,
|
||||
ClearBlocks,
|
||||
AddNextOccurrence,
|
||||
AddCursorAbove,
|
||||
AddCursorBelow,
|
||||
CycleNextSession,
|
||||
CyclePrevSession,
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
Undo,
|
||||
Redo,
|
||||
CommandPalette,
|
||||
AISearch,
|
||||
ClearEditor,
|
||||
Find,
|
||||
SelectAll,
|
||||
Workflows,
|
||||
HistorySearch,
|
||||
SaveCurrentConfig,
|
||||
History,
|
||||
IncreaseFontSize,
|
||||
DecreaseFontSize,
|
||||
ResetFontSize,
|
||||
IncreaseZoom,
|
||||
DecreaseZoom,
|
||||
ResetZoom,
|
||||
RenameTab,
|
||||
SplitPaneRight,
|
||||
SplitPaneLeft,
|
||||
SplitPaneUp,
|
||||
SplitPaneDown,
|
||||
MoveTabLeft,
|
||||
MoveTabRight,
|
||||
ActivateNextTab,
|
||||
ActivatePreviousTab,
|
||||
ActivateNextPane,
|
||||
ActivatePreviousPane,
|
||||
NavigationPalette,
|
||||
SelectBlockAbove,
|
||||
SelectBlockBelow,
|
||||
SelectAllBlocks,
|
||||
CreateBlockPermalink,
|
||||
ToggleBookmarkBlock,
|
||||
FindWithinBlock,
|
||||
CopyBlock,
|
||||
CopyBlockCommand,
|
||||
CopyBlockOutput,
|
||||
ViewSharedBlocks,
|
||||
CloseTab,
|
||||
CloseOtherTabs,
|
||||
CloseTabsRight,
|
||||
ToggleMaximizePane,
|
||||
LaunchConfigPalette,
|
||||
FilesPalette,
|
||||
TriggerWelcomeBlock,
|
||||
CommandSearch,
|
||||
ToggleResourceCenter,
|
||||
ToggleKeybindingsPage,
|
||||
ScrollToTopOfSelectedBlocks,
|
||||
ScrollToBottomOfSelectedBlocks,
|
||||
ToggleSyncAllTerminalInputsInAllTabs,
|
||||
ToggleSyncTerminalInputsInCurrentTab,
|
||||
DisableSyncTerminalInputs,
|
||||
ReopenClosedSession,
|
||||
ToggleWarpDrive,
|
||||
AddWindow,
|
||||
CloseCurrentSession,
|
||||
CloseWindow,
|
||||
NewPersonalWorkflow,
|
||||
NewPersonalNotebook,
|
||||
NewPersonalEnvVars,
|
||||
NewTeamWorkflow,
|
||||
NewTeamNotebook,
|
||||
NewTeamEnvVars,
|
||||
SearchDrive,
|
||||
OpenTeamSettings,
|
||||
ShareCurrentSession,
|
||||
SharePaneContents,
|
||||
#[cfg(windows)]
|
||||
WindowsPaste,
|
||||
#[cfg(windows)]
|
||||
WindowsCopy,
|
||||
/// Also applies to legacy Warp AI (toggles the panel)
|
||||
NewAgentModePane,
|
||||
/// Also applies to legacy Warp AI (attaches the selection to the panel editor)
|
||||
AttachSelectionAsAgentModeContext,
|
||||
OpenAIFactCollection,
|
||||
OpenMCPServerCollection,
|
||||
ToggleProjectExplorer,
|
||||
NewPersonalAIPrompt,
|
||||
NewTeamAIPrompt,
|
||||
OpenRepository,
|
||||
NewTerminalTab,
|
||||
NewAgentTab,
|
||||
GoToLine,
|
||||
ToggleGlobalSearch,
|
||||
ToggleConversationListView,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// Maps for converting from custom tags back to the action enum
|
||||
/// This layer of indirection is necessary because the UI framework can't
|
||||
/// know about particular Warp specific actions, so it deals with all actions
|
||||
/// as plain isizes. Within Warp though we want to deal with them as the enum type.
|
||||
pub static ref CUSTOM_TAG_TO_ACTION: HashMap<isize, CustomAction> = HashMap::from_iter(all::<CustomAction>().map(|action| {
|
||||
(action as isize, action)
|
||||
}));
|
||||
|
||||
/// Regex that matches whether the the normalized form of a [`Keystroke`] matches a control
|
||||
/// character. ASCII control characters constitute the first 31 values of ASCII characters.
|
||||
/// Though they have their own ASCII codepoints, they are typed into the keyboard using
|
||||
/// `ctrl-XX`, see <https://en.wikipedia.org/wiki/Caret_notation>.
|
||||
///
|
||||
/// As an example, the ETX character (represented as `^C` in caret notation) is sent to
|
||||
/// the PTY when the user presses `ctrl-c`.
|
||||
///
|
||||
/// ## Control Characters List
|
||||
/// The full list of these control characters (and their corresponding name) are documented
|
||||
/// below:
|
||||
/// * `^@`: Null
|
||||
/// * `^A`: Start of Header
|
||||
/// * `^B`: Start of Text
|
||||
/// * `^C`: End of Text
|
||||
/// * `^D`: End of Transmission
|
||||
/// * `^E`: Enquiry
|
||||
/// * `^F`: Acknowledge
|
||||
/// * `^G`: Bell
|
||||
/// * `^H`: BackSpace
|
||||
/// * `^I`: Horizontal Tabulation
|
||||
/// * `^J`: Line Feed
|
||||
/// * `^K`: Vertical Tabulation
|
||||
/// * `^L`: Form Feed
|
||||
/// * `^M`: Carriage Return
|
||||
/// * `^N`: Shift Out
|
||||
/// * `^O`: Shift In
|
||||
/// * `^P`: Data Link Escape
|
||||
/// * `^Q`: Device Control 1 (XON)
|
||||
/// * `^R`: Device Control 2
|
||||
/// * `^S`: Device Control 3 (XOFF)
|
||||
/// * `^T`: Device Control 4
|
||||
/// * `^U`: Negative acknowledge
|
||||
/// * `^V`: Synchronous Idle
|
||||
/// * `^W`: End of Transmission Block
|
||||
/// * `^X`: Cancel
|
||||
/// * `^Y`: End of Medium
|
||||
/// * `^Z`: Substitute
|
||||
/// * `^[`: Escape
|
||||
/// * `^\`: File Separator
|
||||
/// * `^]`: Group Separator
|
||||
/// * `^^`: Record Separator
|
||||
/// * `^_`: Unit Separator
|
||||
/// * `^?`: Delete
|
||||
///
|
||||
/// ## Note
|
||||
/// Though caret notation uses uppercase letters (`^C` instead of `^c`), we validate using
|
||||
/// _lowercase_ characters because it is impossible to create a [`Keystroke`] of the form
|
||||
/// `ctrl-[A-Z]`. See [`Keystroke::parse`].
|
||||
pub static ref CONTROL_CHARACTER_KEY_REGEX: Regex = Regex::new(r"^ctrl-[a-z@\[\\\]^_?]$").expect("should be able to construct regex");
|
||||
|
||||
/// Set of actions on Mac that should be considered valid bindings even though they aren't PTY
|
||||
/// compliant. We weren't always diligent about avoiding bindings that could conflict with
|
||||
/// character codes, unfortunately some bindings on Mac currently conflict with the PTY. We have
|
||||
/// this allowlist to special case these legacy actions for the purposes of binding validation.
|
||||
pub static ref MAC_PTY_NON_COMPLIANT_ACTIONS: HashSet<&'static str> = HashSet::from_iter(["terminal:warpify_subshell", "terminal:open_block_list_context_menu_via_keybinding"]);
|
||||
|
||||
/// Set of actions on Windows that should be considered valid bindings even though they aren't
|
||||
/// PTY compliant. Windows users expect pasting to work using both `ctrl-v` and `ctrl-shift-v`,
|
||||
/// so we allowlist the terminal paste action for the purposes of binding validation.
|
||||
pub static ref WINDOWS_PTY_NON_COMPLIANT_KEYSTROKES: HashSet<Keystroke> = HashSet::from_iter([Keystroke::parse("ctrl-v").expect("should be able to construct ctrl-v keystroke")]);
|
||||
|
||||
/// Set of keystrokes that should be considered valid bindings on all platforms even though
|
||||
/// they aren't PTY compliant.
|
||||
pub static ref PTY_NON_COMPLIANT_KEYSTROKES: HashSet<Keystroke> = HashSet::from_iter([
|
||||
// Windows users expect ctrl-c to copy any selected text to the clipboard. To avoid
|
||||
// introducing multiple codepaths for handling ctrl-c, we register ctrl-c as a binding
|
||||
// on TerminalView on all platforms.
|
||||
Keystroke::parse("ctrl-c").expect("should be able to construct ctrl-c keystroke"),
|
||||
// The resume conversation binding uses cmd-shift-R on Mac and should be allowed
|
||||
Keystroke::parse("cmd-shift-R").expect("should be able to construct cmd-shift-R keystroke")
|
||||
]);
|
||||
}
|
||||
|
||||
impl From<CustomAction> for CustomTag {
|
||||
fn from(action: CustomAction) -> Self {
|
||||
action as CustomTag
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CustomTag> for CustomAction {
|
||||
fn from(tag: CustomTag) -> Self {
|
||||
*CUSTOM_TAG_TO_ACTION
|
||||
.get(&tag)
|
||||
.expect("All custom actions are handled.")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_to_keystroke(trigger: &Trigger) -> Option<Keystroke> {
|
||||
match trigger {
|
||||
Trigger::Keystrokes(keys) => keys.first().cloned(),
|
||||
// Custom actions don't have keyboard shortcuts associated with the actions themselves,
|
||||
// they are set separately in app/src/lib.rs as part of creating the Menu. As a result,
|
||||
// we need to map those to the appropriate keyboard shortcut.
|
||||
Trigger::Custom(custom) => custom_tag_to_keystroke(*custom),
|
||||
// Similarly, Standard Actions have their keyboard shortcuts set when creating the menu
|
||||
Trigger::Standard(standard) => match standard {
|
||||
StandardAction::Close => mac_only_keystroke("cmd-shift-W"),
|
||||
// "cmd-q" to quit and "cmd-h" to hide are the standard bindings for these actions on
|
||||
// Mac.
|
||||
StandardAction::Quit => mac_only_keystroke("cmd-q"),
|
||||
StandardAction::Hide => mac_only_keystroke("cmd-h"),
|
||||
StandardAction::HideOtherApps => Keystroke::parse("cmdorctrl-alt-h").ok(),
|
||||
StandardAction::ToggleFullScreen => mac_only_keystroke("cmd-ctrl-f"),
|
||||
StandardAction::Paste => Keystroke::parse(cmd_or_ctrl_shift("v")).ok(),
|
||||
StandardAction::ShowAllApps
|
||||
| StandardAction::BringAllToFront
|
||||
| StandardAction::Minimize
|
||||
| StandardAction::Zoom => None,
|
||||
},
|
||||
Trigger::Empty => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the corresponding [`Keystroke`], if any, of a [`CustomTag`].
|
||||
pub fn custom_tag_to_keystroke(custom: CustomTag) -> Option<Keystroke> {
|
||||
match custom.into() {
|
||||
CustomAction::FocusInput => Keystroke::parse(cmd_or_ctrl_shift("l")).ok(),
|
||||
CustomAction::NewTab => Keystroke::parse(cmd_or_ctrl_shift("t")).ok(),
|
||||
CustomAction::Cut => Keystroke::parse("cmdorctrl-x").ok(),
|
||||
CustomAction::Copy => Keystroke::parse(cmd_or_ctrl_shift("c")).ok(),
|
||||
CustomAction::Paste => Keystroke::parse(cmd_or_ctrl_shift("v")).ok(),
|
||||
#[cfg(windows)]
|
||||
CustomAction::WindowsPaste => Keystroke::parse("ctrl-v").ok(),
|
||||
#[cfg(windows)]
|
||||
CustomAction::WindowsCopy => Keystroke::parse("ctrl-c").ok(),
|
||||
CustomAction::Undo => Keystroke::parse("cmdorctrl-z").ok(),
|
||||
CustomAction::Redo => Keystroke::parse("cmdorctrl-shift-Z").ok(),
|
||||
CustomAction::ClearEditor => Keystroke::parse("ctrl-c").ok(),
|
||||
CustomAction::CycleNextSession => Keystroke::parse("ctrl-tab").ok(),
|
||||
CustomAction::CyclePrevSession => Keystroke::parse("ctrl-shift-tab").ok(),
|
||||
CustomAction::ShowSettings => Keystroke::parse("cmdorctrl-,").ok(),
|
||||
CustomAction::AddNextOccurrence => Keystroke::parse("ctrl-g").ok(),
|
||||
CustomAction::AddCursorAbove => Keystroke::parse("ctrl-shift-up").ok(),
|
||||
CustomAction::AddCursorBelow => Keystroke::parse("ctrl-shift-down").ok(),
|
||||
CustomAction::CommandPalette => Keystroke::parse(cmd_or_ctrl_shift("p")).ok(),
|
||||
CustomAction::AISearch => Keystroke::parse("ctrl-`").ok(),
|
||||
CustomAction::Find => Keystroke::parse(cmd_or_ctrl_shift("f")).ok(),
|
||||
CustomAction::SelectAll => Keystroke::parse("cmdorctrl-a").ok(),
|
||||
CustomAction::CommandSearch => Keystroke::parse("ctrl-r").ok(),
|
||||
CustomAction::Workflows => Keystroke::parse("ctrl-shift-R").ok(),
|
||||
CustomAction::History => Keystroke::parse("up").ok(),
|
||||
CustomAction::IncreaseFontSize => Keystroke::parse("shift-cmdorctrl-+").ok(),
|
||||
CustomAction::DecreaseFontSize => Keystroke::parse("shift-cmdorctrl-_").ok(),
|
||||
CustomAction::ResetFontSize => Keystroke::parse("cmdorctrl-0").ok(),
|
||||
CustomAction::IncreaseZoom => Keystroke::parse("cmdorctrl-=").ok(),
|
||||
CustomAction::DecreaseZoom => Keystroke::parse("cmdorctrl--").ok(),
|
||||
CustomAction::ResetZoom => Keystroke::parse("cmdorctrl-0").ok(),
|
||||
CustomAction::SplitPaneRight => Keystroke::parse(cmd_or_ctrl_shift("d")).ok(),
|
||||
CustomAction::SplitPaneDown => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-shift-D").ok()
|
||||
} else {
|
||||
// On non-Mac platforms, we can't use `ctrl-shift-D` for `SplitPaneRight` since
|
||||
// we already use that for `SplitPaneRight` above. Instead we use
|
||||
// `ctrl-shift-E`, which matches what Hyper uses. See https://github.com/vercel/hyper/blob/9c72409f5138c03a5a74fcc4dba9109217b4524a/app/keymaps/linux.json#L32.
|
||||
Keystroke::parse("ctrl-shift-E").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::MoveTabLeft => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("shift-ctrl-left").ok()
|
||||
} else {
|
||||
Keystroke::parse("shift-ctrl-pageup").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::MoveTabRight => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("shift-ctrl-right").ok()
|
||||
} else {
|
||||
Keystroke::parse("shift-ctrl-pagedown").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::ActivateNextTab => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("shift-cmd-}").ok()
|
||||
} else {
|
||||
Keystroke::parse("ctrl-pagedown").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::ActivatePreviousTab => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("shift-cmd-{").ok()
|
||||
} else {
|
||||
Keystroke::parse("ctrl-pageup").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::ActivateNextPane => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-]").ok()
|
||||
} else {
|
||||
Keystroke::parse("ctrl-shift-}").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::ActivatePreviousPane => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-[").ok()
|
||||
} else {
|
||||
Keystroke::parse("ctrl-shift-{").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::NavigationPalette => mac_only_keystroke("cmd-shift-P"),
|
||||
CustomAction::LaunchConfigPalette => mac_only_keystroke("ctrl-cmd-l"),
|
||||
CustomAction::FilesPalette => Keystroke::parse(cmd_or_ctrl_shift("o")).ok(),
|
||||
CustomAction::ClearBlocks => Keystroke::parse(cmd_or_ctrl_shift("k")).ok(),
|
||||
CustomAction::SelectBlockAbove => Keystroke::parse("cmdorctrl-up").ok(),
|
||||
CustomAction::SelectBlockBelow => Keystroke::parse("cmdorctrl-down").ok(),
|
||||
// Set this to mac-only. On Linux this conflicts with the binding to save a workflow.
|
||||
CustomAction::CreateBlockPermalink => mac_only_keystroke("cmd-shift-S"),
|
||||
CustomAction::ToggleBookmarkBlock => Keystroke::parse(cmd_or_ctrl_shift("b")).ok(),
|
||||
CustomAction::CopyBlockOutput => Keystroke::parse("cmdorctrl-alt-shift-C").ok(),
|
||||
// Set this to mac-only. On Linux this conflicts with the general binding to copy.
|
||||
CustomAction::CopyBlockCommand => mac_only_keystroke("cmd-shift-C"),
|
||||
// Set this to mac-only. On Linux this conflicts with the cmd-enter keybindings
|
||||
// (used for actions on the input suggestions menu, and for accepting passive code diffs).
|
||||
CustomAction::ToggleMaximizePane => mac_only_keystroke("cmd-shift-enter"),
|
||||
// Note: The base character '/' is used instead of '?' as mac registers keybindings
|
||||
// differently compared to the app which saves the resulting character used with shift
|
||||
// TODO: resolve these keybinding differences
|
||||
CustomAction::ToggleResourceCenter => Keystroke::parse("ctrl-shift-/").ok(),
|
||||
CustomAction::ToggleKeybindingsPage => Keystroke::parse("cmdorctrl-/").ok(),
|
||||
CustomAction::ScrollToTopOfSelectedBlocks => Keystroke::parse("cmdorctrl-shift-up").ok(),
|
||||
CustomAction::ScrollToBottomOfSelectedBlocks => {
|
||||
Keystroke::parse("cmdorctrl-shift-down").ok()
|
||||
}
|
||||
CustomAction::CopyBlock => Keystroke::parse(cmd_or_ctrl_shift("c")).ok(),
|
||||
CustomAction::FindWithinBlock => Keystroke::parse(cmd_or_ctrl_shift("f")).ok(),
|
||||
CustomAction::ToggleSyncTerminalInputsInCurrentTab => {
|
||||
Keystroke::parse("alt-cmdorctrl-i").ok()
|
||||
}
|
||||
CustomAction::ReopenClosedSession => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-shift-T").ok()
|
||||
} else {
|
||||
// Use a custom keybinding for linux/windows since the binding would otherwise
|
||||
// conflict with the binding for creating a new tab.
|
||||
Keystroke::parse("ctrl-alt-t").ok()
|
||||
}
|
||||
}
|
||||
|
||||
// This is one of the app's hardcoded keybindings.
|
||||
CustomAction::AddWindow => Keystroke::parse(cmd_or_ctrl_shift("n")).ok(),
|
||||
CustomAction::ToggleWarpDrive => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-\\").ok()
|
||||
} else {
|
||||
Keystroke::parse("ctrl-shift-|").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::CloseWindow => mac_only_keystroke("cmd-shift-W"),
|
||||
CustomAction::CloseCurrentSession => Keystroke::parse(cmd_or_ctrl_shift("w")).ok(),
|
||||
CustomAction::ViewChangelog => Keystroke::parse(cmd_or_ctrl_shift("alt-o")).ok(),
|
||||
CustomAction::NewAgentModePane => Keystroke::parse("ctrl-space").ok(),
|
||||
CustomAction::AttachSelectionAsAgentModeContext => {
|
||||
Keystroke::parse("ctrl-shift-space").ok()
|
||||
}
|
||||
CustomAction::ToggleProjectExplorer => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("ctrl-2").ok()
|
||||
} else {
|
||||
Keystroke::parse("ctrl-shift-2").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::OpenRepository => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-shift-O").ok()
|
||||
} else {
|
||||
Keystroke::parse("alt-shift-O").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::GoToLine => Keystroke::parse("ctrl-g").ok(),
|
||||
CustomAction::ToggleGlobalSearch => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("ctrl-3").ok()
|
||||
} else {
|
||||
Keystroke::parse("alt-3").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::ToggleConversationListView => {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("ctrl-1").ok()
|
||||
} else {
|
||||
Keystroke::parse("alt-1").ok()
|
||||
}
|
||||
}
|
||||
CustomAction::NewTerminalTab
|
||||
| CustomAction::NewFile
|
||||
| CustomAction::ShowAboutWarp
|
||||
| CustomAction::SplitPaneLeft
|
||||
| CustomAction::SelectAllBlocks
|
||||
| CustomAction::SplitPaneUp
|
||||
| CustomAction::ConfigureKeybindings
|
||||
| CustomAction::RenameTab
|
||||
| CustomAction::CloseTab
|
||||
| CustomAction::CloseOtherTabs
|
||||
| CustomAction::CloseTabsRight
|
||||
| CustomAction::ReferAFriend
|
||||
| CustomAction::ViewSharedBlocks
|
||||
| CustomAction::ShowAccount
|
||||
| CustomAction::ShowAppearance
|
||||
| CustomAction::SaveCurrentConfig
|
||||
| CustomAction::TriggerWelcomeBlock
|
||||
| CustomAction::HistorySearch
|
||||
| CustomAction::DisableSyncTerminalInputs
|
||||
| CustomAction::ToggleSyncAllTerminalInputsInAllTabs
|
||||
| CustomAction::NewPersonalWorkflow
|
||||
| CustomAction::NewPersonalNotebook
|
||||
| CustomAction::NewPersonalEnvVars
|
||||
| CustomAction::NewTeamWorkflow
|
||||
| CustomAction::NewTeamNotebook
|
||||
| CustomAction::NewTeamEnvVars
|
||||
| CustomAction::SearchDrive
|
||||
| CustomAction::OpenTeamSettings
|
||||
| CustomAction::ShareCurrentSession
|
||||
| CustomAction::SharePaneContents
|
||||
| CustomAction::OpenAIFactCollection
|
||||
| CustomAction::OpenMCPServerCollection
|
||||
| CustomAction::NewPersonalAIPrompt
|
||||
| CustomAction::NewTeamAIPrompt
|
||||
| CustomAction::NewAgentTab => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the keystroke currently assigned to a binding. Returns `None` if the binding does not exist
|
||||
/// or is unassigned.
|
||||
pub fn keybinding_name_to_keystroke(binding_name: &str, ctx: &AppContext) -> Option<Keystroke> {
|
||||
ctx.get_binding_by_name(binding_name)
|
||||
.and_then(|binding| trigger_to_keystroke(binding.trigger))
|
||||
}
|
||||
|
||||
/// Get keybinding display string from binding name. Unset keybindings will return None.
|
||||
pub fn keybinding_name_to_display_string(binding_name: &str, ctx: &AppContext) -> Option<String> {
|
||||
keybinding_name_to_keystroke(binding_name, ctx).map(|keystroke| keystroke.displayed())
|
||||
}
|
||||
|
||||
/// Get normalized keybinding string from binding name. Unset keybindings will return None.
|
||||
pub fn keybinding_name_to_normalized_string(
|
||||
binding_name: &str,
|
||||
ctx: &AppContext,
|
||||
) -> Option<String> {
|
||||
keybinding_name_to_keystroke(binding_name, ctx).map(|keystroke| keystroke.normalized())
|
||||
}
|
||||
|
||||
/// Sets a custom keybinding for an editable binding using the given keystroke. Will
|
||||
/// persist the keybinding to the user's config file and emit a KeybindingChangedEvent.
|
||||
pub fn set_custom_keybinding(binding_name: &str, keystroke: &Keystroke, ctx: &mut AppContext) {
|
||||
ctx.set_custom_trigger(
|
||||
binding_name.into(),
|
||||
Trigger::Keystrokes(vec![keystroke.clone()]),
|
||||
);
|
||||
write_custom_keybinding(
|
||||
binding_name.into(),
|
||||
UserDefinedKeybinding::keystroke(keystroke.clone()),
|
||||
);
|
||||
KeybindingChangedNotifier::handle(ctx).update(ctx, |_, ctx| {
|
||||
ctx.emit(KeybindingChangedEvent::BindingChanged {
|
||||
binding_name: binding_name.into(),
|
||||
new_trigger: Some(keystroke.clone()),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset an editable binding back to its default trigger. Will persist this change to
|
||||
/// the user's config file and emit a KeybindingChangedEvent with the default trigger.
|
||||
/// Returns the default keystroke for the binding.
|
||||
pub fn reset_keybinding_to_default(binding_name: &str, ctx: &mut AppContext) -> Option<Keystroke> {
|
||||
ctx.remove_custom_trigger(binding_name);
|
||||
remove_custom_keybinding(binding_name);
|
||||
|
||||
let default_keystroke = ctx
|
||||
.editable_bindings()
|
||||
.find(|binding| binding.name == binding_name)
|
||||
.and_then(|binding| trigger_to_keystroke(binding.trigger));
|
||||
|
||||
KeybindingChangedNotifier::handle(ctx).update(ctx, |_, ctx| {
|
||||
ctx.emit(KeybindingChangedEvent::BindingChanged {
|
||||
binding_name: binding_name.into(),
|
||||
new_trigger: default_keystroke.clone(),
|
||||
})
|
||||
});
|
||||
|
||||
default_keystroke
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommandBinding {
|
||||
pub name: String,
|
||||
pub description: BindingDescription,
|
||||
pub trigger: Option<Keystroke>,
|
||||
pub action: Option<Arc<dyn Action>>,
|
||||
pub group: Option<BindingGroup>,
|
||||
/// The ID of the binding. If the [`CommandBinding`] was created from an
|
||||
/// [`EditableBindingLens`] or [`BindingLens`] this is the id of the lens. Otherwise a new ID
|
||||
/// is constructed.
|
||||
pub id: BindingId,
|
||||
}
|
||||
|
||||
/// SearchScore is a helper struct for ranking the result of the search.
|
||||
/// `keystroke_score` represents the proximity between search term keystroke with
|
||||
/// the candidate keystroke. If the score is None, it means the candidate keystroke is not
|
||||
/// a valid subset of the search term keystroke.
|
||||
/// The fuzzy_search_score helps on the secondary ranking -- if two candidate keystrokes are
|
||||
/// both not valid subset of the search term keystroke, then we rank these by the score
|
||||
/// of the fuzzy_search.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct SearchScore {
|
||||
keystroke_score: Option<usize>,
|
||||
fuzzy_search_score: i64,
|
||||
}
|
||||
|
||||
impl Ord for SearchScore {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match self.keystroke_score.cmp(&other.keystroke_score) {
|
||||
Ordering::Equal => self.fuzzy_search_score.cmp(&other.fuzzy_search_score),
|
||||
ordering => ordering,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for SearchScore {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_search_term_to_keystroke(search_term: &str) -> Option<Keystroke> {
|
||||
let mut search_keystroke: Keystroke = Default::default();
|
||||
let mut key_set = false;
|
||||
|
||||
for element in search_term.split_whitespace() {
|
||||
match element.to_ascii_lowercase().as_str() {
|
||||
"command" | "cmd" => {
|
||||
if search_keystroke.cmd {
|
||||
return None;
|
||||
}
|
||||
search_keystroke.cmd = true
|
||||
}
|
||||
"control" | "ctrl" => {
|
||||
if search_keystroke.ctrl {
|
||||
return None;
|
||||
}
|
||||
search_keystroke.ctrl = true
|
||||
}
|
||||
"alt" | "option" => {
|
||||
if search_keystroke.alt {
|
||||
return None;
|
||||
}
|
||||
search_keystroke.alt = true
|
||||
}
|
||||
"shift" => {
|
||||
if search_keystroke.shift {
|
||||
return None;
|
||||
}
|
||||
search_keystroke.shift = true
|
||||
}
|
||||
"meta" => {
|
||||
if search_keystroke.meta {
|
||||
return None;
|
||||
}
|
||||
search_keystroke.meta = true
|
||||
}
|
||||
key => {
|
||||
if key_set {
|
||||
return None;
|
||||
}
|
||||
key_set = true;
|
||||
search_keystroke.key = key.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internally we uppercase the key when shift modifier is true.
|
||||
if search_keystroke.shift && search_keystroke.key.len() == 1 {
|
||||
search_keystroke.key = search_keystroke.key.to_ascii_uppercase();
|
||||
}
|
||||
|
||||
Some(search_keystroke)
|
||||
}
|
||||
|
||||
pub fn filter_bindings_including_keystroke<'a>(
|
||||
bindings_iter: impl Iterator<Item = &'a CommandBinding>,
|
||||
search_term: &str,
|
||||
description_for: DescriptionContext,
|
||||
) -> impl Iterator<Item = (Option<Vec<usize>>, &'a CommandBinding)> {
|
||||
let search_keystroke = convert_search_term_to_keystroke(search_term);
|
||||
|
||||
bindings_iter
|
||||
.filter_map(move |binding| {
|
||||
if search_term.is_empty() {
|
||||
Some((Default::default(), None, binding))
|
||||
} else {
|
||||
let keystroke_search_score = if let Some(search_keystroke) = &search_keystroke {
|
||||
binding.trigger.as_ref().and_then(|candidate_keystroke| {
|
||||
let score = keystroke_includes(search_keystroke, candidate_keystroke);
|
||||
if score > 0 {
|
||||
Some(score)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let fuzzy_search_result = match_indices_case_insensitive(
|
||||
binding.description.in_context(description_for),
|
||||
search_term,
|
||||
);
|
||||
|
||||
match (keystroke_search_score, fuzzy_search_result) {
|
||||
// If keystroke matched, don't include fuzzy search highlights.
|
||||
(Some(keystroke_score), Some(fuzzy_search_result)) => Some((
|
||||
SearchScore {
|
||||
keystroke_score: Some(keystroke_score),
|
||||
fuzzy_search_score: fuzzy_search_result.score,
|
||||
},
|
||||
None,
|
||||
binding,
|
||||
)),
|
||||
(None, Some(fuzzy_search_result)) => Some((
|
||||
SearchScore {
|
||||
fuzzy_search_score: fuzzy_search_result.score,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
binding,
|
||||
)),
|
||||
(Some(keystroke_score), None) => Some((
|
||||
SearchScore {
|
||||
keystroke_score: Some(keystroke_score),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
binding,
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.sorted_by(|(score1, _, _), (score2, _, _)| score2.cmp(score1))
|
||||
.map(|(_, indices, binding)| (indices, binding))
|
||||
}
|
||||
|
||||
/// Check if the keystroke could be a possible candidate of the search keystroke and give a score
|
||||
/// based on proximity.
|
||||
/// The scores are generated as follows: for each field of the keystroke (alt, cmd, etc), the comparison
|
||||
/// between the search and candidate keystroke could yield three possible results - a strict
|
||||
/// match (candidate and search has the same value), a potential match (candidate has the value
|
||||
/// set to true but search is missing the value), a mismatch (candidate has the value set to
|
||||
/// false but search is set to true).
|
||||
/// For a strict match, we increase the score by multiplying it by two. For a potential match,
|
||||
/// we keep the original score by multiplying it by one. For a mismatch, we multiply by zero
|
||||
/// to mark that the candidate keystroke is not a valid subset of the search keystroke.
|
||||
fn keystroke_includes(search_keystroke: &Keystroke, candidate_keystroke: &Keystroke) -> usize {
|
||||
fn modifier_match(
|
||||
search_keystroke_condition: bool,
|
||||
candidate_keystroke_condition: bool,
|
||||
) -> usize {
|
||||
match (search_keystroke_condition, candidate_keystroke_condition) {
|
||||
(false, false) | (true, true) => 2, // match gives a score of 2.
|
||||
(false, true) => 1, // keep the same score if the keystroke term is true but search_keystroke does not include the term.
|
||||
(true, false) => 0, // if the keystroke term is false but search_keystorke is true, return 0 score
|
||||
}
|
||||
}
|
||||
|
||||
let key_match_score = if search_keystroke.key == candidate_keystroke.key {
|
||||
2
|
||||
} else {
|
||||
usize::from(search_keystroke.key.is_empty())
|
||||
};
|
||||
|
||||
modifier_match(search_keystroke.alt, candidate_keystroke.alt)
|
||||
* modifier_match(search_keystroke.cmd, candidate_keystroke.cmd)
|
||||
* modifier_match(search_keystroke.ctrl, candidate_keystroke.ctrl)
|
||||
* modifier_match(search_keystroke.meta, candidate_keystroke.meta)
|
||||
* modifier_match(search_keystroke.shift, candidate_keystroke.shift)
|
||||
* key_match_score
|
||||
}
|
||||
|
||||
impl CommandBinding {
|
||||
pub fn new(name: String, description: String, trigger: Option<Keystroke>) -> Self {
|
||||
CommandBinding {
|
||||
name,
|
||||
description: BindingDescription::new(description),
|
||||
trigger,
|
||||
action: None,
|
||||
group: None,
|
||||
id: BindingId::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Materializes a [`CommandBinding`] from a [`BindingLens`], resolving
|
||||
/// any dynamic description against `ctx` so downstream consumers that
|
||||
/// have no `&AppContext` (fuzzy/full-text search indices, accessibility
|
||||
/// labels, render paths that only see an `Appearance`) observe a plain
|
||||
/// string.
|
||||
///
|
||||
/// This is intentionally the only way to build a `CommandBinding` from
|
||||
/// a lens; taking `&AppContext` by value here forces every cache-
|
||||
/// population site to thread context through, which in turn guarantees
|
||||
/// that a future dynamic description cannot silently go unresolved.
|
||||
///
|
||||
/// Returns `None` when the source binding has no description.
|
||||
pub fn from_lens(lens: BindingLens<'_>, ctx: &AppContext) -> Option<Self> {
|
||||
lens.description.map(|desc| CommandBinding {
|
||||
description: materialize_description(desc, ctx),
|
||||
trigger: trigger_to_keystroke(lens.trigger),
|
||||
action: Some(lens.action.clone()),
|
||||
name: lens.name.to_string(),
|
||||
group: lens.group.and_then(BindingGroup::from_str),
|
||||
id: lens.id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Materializes a [`CommandBinding`] from an [`EditableBindingLens`].
|
||||
/// See [`Self::from_lens`] for why this takes `&AppContext`.
|
||||
pub fn from_editable_lens(lens: EditableBindingLens<'_>, ctx: &AppContext) -> Self {
|
||||
Self {
|
||||
description: materialize_description(lens.description, ctx),
|
||||
trigger: trigger_to_keystroke(lens.trigger),
|
||||
action: Some(lens.action.clone()),
|
||||
name: lens.name.into(),
|
||||
group: lens.group.and_then(BindingGroup::from_str),
|
||||
id: lens.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn placeholder(placeholder: String) -> Self {
|
||||
CommandBinding {
|
||||
name: Default::default(),
|
||||
description: placeholder.into(),
|
||||
trigger: None,
|
||||
action: None,
|
||||
group: None,
|
||||
id: BindingId::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_description(desc: &BindingDescription, ctx: &AppContext) -> BindingDescription {
|
||||
if desc.has_dynamic_override() {
|
||||
desc.materialized(ctx)
|
||||
} else {
|
||||
desc.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Possible groups a Binding can be part of. The string representation (produced in
|
||||
/// [`BindingGroup::as_str`]) is used as the group identifier within
|
||||
/// [`warpui::keymap::FixedBinding`] or [`EditableBinding`].
|
||||
#[derive(Copy, Clone, Debug, Sequence)]
|
||||
pub enum BindingGroup {
|
||||
Settings,
|
||||
Close,
|
||||
Navigation,
|
||||
WarpAi,
|
||||
Workflow,
|
||||
Notebooks,
|
||||
Folders,
|
||||
KeyboardShortcuts,
|
||||
AutoUpdate,
|
||||
Notifications,
|
||||
EnvVarCollection,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
impl BindingGroup {
|
||||
/// Returns a string representation of the [`BindingGroup`].
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Settings => "settings",
|
||||
Self::WarpAi => "warp_ai",
|
||||
Self::Navigation => "navigation",
|
||||
Self::Workflow => "workflows",
|
||||
Self::Notebooks => "notebooks",
|
||||
Self::Folders => "folders",
|
||||
Self::KeyboardShortcuts => "keyboard_shortcuts",
|
||||
Self::Close => "close",
|
||||
Self::AutoUpdate => "autoupdate",
|
||||
Self::Notifications => "notifications",
|
||||
Self::EnvVarCollection => "env_var_collections",
|
||||
Self::Terminal => "terminal",
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a [`BindingGroup`] from a str. Returns `None` if there is no group that corresponds
|
||||
/// to the `str`.
|
||||
fn from_str(str: &'static str) -> Option<Self> {
|
||||
all::<Self>().find(|&item| item.as_str() == str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a keybinding that is the `cmd-key` on Mac or `ctrl-shift-key` otherwise. This is
|
||||
/// useful when constructing a binding that needs to be compliant with the PTY. The typical pattern
|
||||
/// of using `cmdorctrl-XX` to construct a platform agnostic keybinding does not work here because
|
||||
/// `ctrl-XX` would conflict with the PTY because it is reserved as a control character.
|
||||
///
|
||||
/// Bindings of the form `ctrl-[a-z@[\]^_?]` are reserved as control characters. We don't want to
|
||||
/// create bindings for in-app actions that would conflict with these control characters because we
|
||||
/// would end up preventing the user from sending these control characters to the PTY. To avoid
|
||||
/// this, we follow other terminals and use `ctrl-shift-XX` for in-app bindings if the binding would
|
||||
/// otherwise conflict with the PTY.
|
||||
///
|
||||
/// ## Panics
|
||||
/// Panics if debug assertions are enabled and a non "A-Z" key was passed in an environment where `ctrl-shift` would be
|
||||
/// used. This is because the passed key needs to modified by the shift character in order to be valid in our UI
|
||||
/// framework and we can't easily produce the shift-modified version of the key ourselves. In this case the recommended
|
||||
/// solution is to to create separate [`Keystroke`]s for the Mac and non-Mac cases. For example:
|
||||
/// ```
|
||||
/// use warpui::keymap::Keystroke;
|
||||
/// use warpui::platform::OperatingSystem;
|
||||
/// let keystroke = if OperatingSystem::get().is_mac() {
|
||||
/// Keystroke::parse("cmd-[")
|
||||
/// } else {
|
||||
/// Keystroke::parse("ctrl-shift-{")
|
||||
/// };
|
||||
/// ```
|
||||
pub fn cmd_or_ctrl_shift(key: &str) -> String {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
format!("cmd-{key}")
|
||||
} else {
|
||||
let key = if Keystroke::is_valid_special_key(key) {
|
||||
// Valid keys don't need to be uppercase (we don't want to create a binding that looks
|
||||
// like `ctrl-shift-ENTER`).
|
||||
Cow::Borrowed(key)
|
||||
} else {
|
||||
if cfg!(debug_assertions) {
|
||||
let stroke = key.chars().next().expect("Character should exist");
|
||||
|
||||
if !stroke.is_ascii_lowercase() {
|
||||
panic!(
|
||||
"Tried to register a ctrl-shift-{key} shortcut which is invalid because the {key} character needs to be modified by the shift character."
|
||||
);
|
||||
}
|
||||
}
|
||||
// The need to uppercase the key because of the addition of the `shift`.
|
||||
// Keystroke::parse debug asserts if this the modifier is lowercase:
|
||||
// https://github.com/warpdotdev/warp-internal/blob/c225b8cedd94fdba33e957cf1efb99d84768d193/ui/src/keymap.rs#L637/
|
||||
key.to_ascii_uppercase().into()
|
||||
};
|
||||
format!("ctrl-shift-{key}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the given [`BindingLens`] is compliant with the PTY.
|
||||
/// A binding is considered PTY compliant if it does not interfere with a control character that
|
||||
/// needs to be sent to the PTY. A binding is considered to be a control character if the only
|
||||
/// modifier set is `ctrl` and the key is one of `a-z@[\]^_?`.
|
||||
pub fn is_binding_pty_compliant(binding: BindingLens) -> IsBindingValid {
|
||||
let trigger = binding.original_trigger.unwrap_or(binding.trigger);
|
||||
let Some(keystroke) = trigger_to_keystroke(trigger) else {
|
||||
return IsBindingValid::Yes;
|
||||
};
|
||||
|
||||
let is_binding_in_allowlist = (OperatingSystem::get().is_mac()
|
||||
&& MAC_PTY_NON_COMPLIANT_ACTIONS.contains(binding.name))
|
||||
|| (OperatingSystem::get().is_windows()
|
||||
&& WINDOWS_PTY_NON_COMPLIANT_KEYSTROKES.contains(&keystroke))
|
||||
|| PTY_NON_COMPLIANT_KEYSTROKES.contains(&keystroke);
|
||||
|
||||
if CONTROL_CHARACTER_KEY_REGEX.is_match(keystroke.normalized().as_str())
|
||||
&& !is_binding_in_allowlist
|
||||
{
|
||||
// The binding interferes with a control character so it is not valid.
|
||||
IsBindingValid::No
|
||||
} else {
|
||||
IsBindingValid::Yes
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates all that bindings are cross-platform by returning [`IsBindingValid::No`] if a `cmd-*`
|
||||
/// binding is used on non-mac platforms.
|
||||
pub fn is_binding_cross_platform(binding: BindingLens) -> IsBindingValid {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
return IsBindingValid::Yes;
|
||||
};
|
||||
|
||||
let trigger = binding.original_trigger.unwrap_or(binding.trigger);
|
||||
let Some(keystroke) = trigger_to_keystroke(trigger) else {
|
||||
return IsBindingValid::Yes;
|
||||
};
|
||||
|
||||
if keystroke.cmd {
|
||||
IsBindingValid::No
|
||||
} else {
|
||||
IsBindingValid::Yes
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to construct a [`Keystroke`] from the given source string if the current
|
||||
/// [`OperatingSystem`] is mac. Returns `None` if not on Mac or if a [`Keystroke`] was unable to be
|
||||
/// constructed from the source string.
|
||||
fn mac_only_keystroke(source: &str) -> Option<Keystroke> {
|
||||
if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse(source).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "bindings_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,74 @@
|
||||
use warpui::platform::OperatingSystem;
|
||||
use warpui::{
|
||||
keymap::{EditableBinding, Keystroke, Trigger},
|
||||
App,
|
||||
};
|
||||
|
||||
use crate::{util::bindings::keybinding_name_to_display_string, workspace::WorkspaceAction};
|
||||
|
||||
#[test]
|
||||
fn test_keybinding_name_to_display_string() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
ctx.register_editable_bindings([
|
||||
EditableBinding::new(
|
||||
"workspace:show_settings",
|
||||
"Open settings",
|
||||
WorkspaceAction::ShowSettings,
|
||||
)
|
||||
.with_key_binding("cmd-,"),
|
||||
EditableBinding::new(
|
||||
"workspace:toggle_resource_center",
|
||||
"Toggle Resource Center",
|
||||
WorkspaceAction::ToggleResourceCenter,
|
||||
),
|
||||
]);
|
||||
|
||||
let displayed_keybinding = if OperatingSystem::get().is_mac() {
|
||||
"⌘,"
|
||||
} else {
|
||||
"Logo ,"
|
||||
};
|
||||
assert_eq!(
|
||||
Some(displayed_keybinding),
|
||||
keybinding_name_to_display_string("workspace:show_settings", ctx).as_deref()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
None,
|
||||
keybinding_name_to_display_string("workspace:toggle_resource_center", ctx)
|
||||
);
|
||||
|
||||
ctx.set_custom_trigger(
|
||||
"workspace:show_settings".to_owned(),
|
||||
Trigger::Keystrokes(vec![Keystroke::parse("cmd-shift-<").unwrap()]),
|
||||
);
|
||||
|
||||
let displayed_keybinding = if OperatingSystem::get().is_mac() {
|
||||
"⇧⌘<"
|
||||
} else {
|
||||
"Shift Logo <"
|
||||
};
|
||||
assert_eq!(
|
||||
Some(displayed_keybinding),
|
||||
keybinding_name_to_display_string("workspace:show_settings", ctx).as_deref()
|
||||
);
|
||||
|
||||
ctx.set_custom_trigger(
|
||||
"workspace:toggle_resource_center".to_owned(),
|
||||
Trigger::Keystrokes(vec![Keystroke::parse("cmd-alt-/").unwrap()]),
|
||||
);
|
||||
|
||||
let expected_keybinding = if OperatingSystem::get().is_mac() {
|
||||
"⌥⌘/"
|
||||
} else {
|
||||
"Alt Logo /"
|
||||
};
|
||||
assert_eq!(
|
||||
Some(expected_keybinding),
|
||||
keybinding_name_to_display_string("workspace:toggle_resource_center", ctx)
|
||||
.as_deref()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use itertools::Itertools;
|
||||
use warp_util::path::ShellFamily;
|
||||
use warpui::clipboard::ClipboardContent;
|
||||
|
||||
/// Returns a string representation of the ClipboardContent with any paths properly escaped if there is a known shell. If not, do not escape the paths.
|
||||
pub fn clipboard_content_with_escaped_paths(
|
||||
mut content: ClipboardContent,
|
||||
shell_family: Option<ShellFamily>,
|
||||
replace_newlines_with_spaces: bool,
|
||||
) -> String {
|
||||
if replace_newlines_with_spaces {
|
||||
content = ClipboardContent {
|
||||
plain_text: content.plain_text.replace("\n", " ").to_string(),
|
||||
..content
|
||||
}
|
||||
}
|
||||
match content.paths {
|
||||
Some(paths) => paths
|
||||
.iter()
|
||||
.map(|path| match shell_family {
|
||||
Some(shell_family) => shell_family.escape(path),
|
||||
None => Cow::Borrowed(path.as_ref()),
|
||||
})
|
||||
.join(" "),
|
||||
None => content.plain_text,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub use warp_core::ui::color::contrast::*;
|
||||
pub use warp_core::ui::color::*;
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
pub trait SliceExt<T: 'static> {
|
||||
fn find_insertion_index<'a, F, E>(&'a self, compare: F) -> Result<usize, E>
|
||||
where
|
||||
F: FnMut(&'a T) -> Result<Ordering, E>;
|
||||
}
|
||||
|
||||
impl<T: 'static> SliceExt<T> for [T] {
|
||||
fn find_insertion_index<'a, F, E>(&'a self, mut f: F) -> Result<usize, E>
|
||||
where
|
||||
F: FnMut(&'a T) -> Result<Ordering, E>,
|
||||
{
|
||||
use Ordering::*;
|
||||
|
||||
let mut size = self.len();
|
||||
if size == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut base = 0usize;
|
||||
while size > 1 {
|
||||
let half = size / 2;
|
||||
let mid = base + half;
|
||||
// mid is always in [0, size), that means mid is >= 0 and < size.
|
||||
// mid >= 0: by definition
|
||||
// mid < size: mid = size / 2 + size / 4 + size / 8 ...
|
||||
let cmp = f(unsafe { self.get_unchecked(mid) })?;
|
||||
base = if cmp == Greater { base } else { mid };
|
||||
size -= half;
|
||||
}
|
||||
// base is always in [0, size) because base <= mid.
|
||||
let cmp = f(unsafe { self.get_unchecked(base) })?;
|
||||
if cmp == Equal {
|
||||
Ok(base)
|
||||
} else {
|
||||
Ok(base + (cmp == Less) as usize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TrimStringExt {
|
||||
fn trim_trailing_newline(&mut self);
|
||||
}
|
||||
|
||||
impl TrimStringExt for String {
|
||||
fn trim_trailing_newline(&mut self) {
|
||||
if self.ends_with('\n') {
|
||||
self.pop();
|
||||
}
|
||||
if self.ends_with('\r') {
|
||||
self.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
pub mod external_editor;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(windows)]
|
||||
use warp_util::path::is_network_resource;
|
||||
use warp_util::path::{CleanPathResult, LineAndColumnArg};
|
||||
|
||||
use crate::terminal::model::grid::grid_handler::{ContainsPoint, Link};
|
||||
use crate::terminal::model::index::Point;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
|
||||
pub use self::external_editor::{open_file_path_in_external_editor, open_file_path_with_editor};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FilePathType {
|
||||
Absolute,
|
||||
/// Contains the working directory PathBuf.
|
||||
Relative(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ShellPathType {
|
||||
/// The path comes from the shell and may need to be converted in a shell-aware way.
|
||||
ShellNative(String),
|
||||
/// The path has already been converted to a OS-native path.
|
||||
PlatformNative(PathBuf),
|
||||
}
|
||||
|
||||
/// Checks if a file path exists and is valid for a file link.
|
||||
pub fn absolute_path_if_valid(
|
||||
clean_path_result: &CleanPathResult,
|
||||
working_directory: ShellPathType,
|
||||
shell_launch_data: Option<&ShellLaunchData>,
|
||||
) -> Option<PathBuf> {
|
||||
let (maybe_absolute_path, relative_path) = match shell_launch_data {
|
||||
Some(shell_launch_data) => {
|
||||
// Attempt to parse the clean path result as an absolute path.
|
||||
let maybe_absolute_path =
|
||||
shell_launch_data.maybe_convert_absolute_path(&clean_path_result.path);
|
||||
let relative_path = match working_directory {
|
||||
ShellPathType::ShellNative(base_path_str) => shell_launch_data
|
||||
.maybe_convert_relative_path(&base_path_str, &clean_path_result.path),
|
||||
ShellPathType::PlatformNative(base_path) => {
|
||||
shell_launch_data.join_to_native_path(&base_path, &clean_path_result.path)
|
||||
}
|
||||
};
|
||||
(maybe_absolute_path, relative_path)
|
||||
}
|
||||
None => {
|
||||
// We naively attempt to treat the given paths as platform-native.
|
||||
let maybe_absolute_path = PathBuf::from(&clean_path_result.path);
|
||||
let relative_path = match working_directory {
|
||||
ShellPathType::ShellNative(path_str) => {
|
||||
let mut path_buf = PathBuf::from(path_str);
|
||||
path_buf.push(&clean_path_result.path);
|
||||
path_buf
|
||||
}
|
||||
ShellPathType::PlatformNative(path_buf) => path_buf.join(&clean_path_result.path),
|
||||
};
|
||||
(Some(maybe_absolute_path), Some(relative_path))
|
||||
}
|
||||
};
|
||||
|
||||
if relative_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| is_path_valid(path, clean_path_result))
|
||||
{
|
||||
return relative_path;
|
||||
} else if maybe_absolute_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| is_path_valid(path, clean_path_result))
|
||||
{
|
||||
return maybe_absolute_path;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_path_valid(path: &Path, clean_path_result: &CleanPathResult) -> bool {
|
||||
// Checking for the existence of a network resource takes a long time (~15s),
|
||||
// and hangs the UI, so we skip validating it.
|
||||
#[cfg(windows)]
|
||||
if is_network_resource(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// It should only be a valid path if the path links to a file or a folder without
|
||||
// line and column number attached.
|
||||
let Ok(metadata) = fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
metadata.is_file() || (metadata.is_dir() && clean_path_result.line_and_column_num.is_none())
|
||||
}
|
||||
|
||||
impl FilePathType {
|
||||
/// Given a path that we've identified the FilePathType of,
|
||||
/// returns the absolute path.
|
||||
pub fn absolute_path(&self, path: PathBuf) -> PathBuf {
|
||||
match self {
|
||||
FilePathType::Absolute => path,
|
||||
FilePathType::Relative(directory) => directory.join(&path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FileLink {
|
||||
pub link: Link,
|
||||
/// This path has been converted (if needed) into a native path from the shell.
|
||||
pub absolute_path: PathBuf,
|
||||
pub line_and_column_num: Option<LineAndColumnArg>,
|
||||
}
|
||||
|
||||
impl FileLink {
|
||||
pub fn absolute_path(&self) -> Option<PathBuf> {
|
||||
Some(self.absolute_path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainsPoint for FileLink {
|
||||
fn contains(&self, point: Point) -> bool {
|
||||
self.link.contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the file at the given path if it doesn't already exist, opening it
|
||||
/// in write mode. If any directories in the path are missing, those are created
|
||||
/// as well.
|
||||
///
|
||||
/// This always returns an error for unit tests, as they should not directly
|
||||
/// interact with the filesystem.
|
||||
pub fn create_file<P: AsRef<Path>>(_path: P) -> io::Result<fs::File> {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(test)] {
|
||||
Err(io::Error::from_raw_os_error(1))
|
||||
} else {
|
||||
let path = _path.as_ref();
|
||||
fs::create_dir_all(path.parent().ok_or_else(|| {
|
||||
io::Error::other(
|
||||
"full_path should never be root directory.",
|
||||
)
|
||||
})?)?;
|
||||
fs::File::create(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use command::blocking::Command;
|
||||
use freedesktop_desktop_entry::DesktopEntry;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::AppContext;
|
||||
|
||||
use super::Editor;
|
||||
|
||||
static INSTALLED_EDITOR_METADATA: OnceLock<HashMap<Editor, EditorMetadata>> = OnceLock::new();
|
||||
|
||||
/// A data struct to hold relevant info pulled from a [freedesktop_desktop_entry::DesktopEntry].
|
||||
/// Mostly here to get around the lack of an owned version of DesktopEntry.
|
||||
struct EditorMetadata {
|
||||
/// Path to the .desktop file.
|
||||
desktop_file_path: PathBuf,
|
||||
|
||||
/// The EXEC string from the .desktop file that details how
|
||||
/// to open the application. Contains field codes that need
|
||||
/// to be replaced.
|
||||
exec: String,
|
||||
|
||||
/// The name of the app, localized to the user's language if
|
||||
/// possible.
|
||||
localized_name: Option<String>,
|
||||
|
||||
// Path to a desktop icon.
|
||||
icon: Option<String>,
|
||||
}
|
||||
|
||||
impl EditorMetadata {
|
||||
/// Builds a new metadata from a given desktop file path
|
||||
///
|
||||
/// Reads in the file at `desktop_file_path`, and Attempts
|
||||
/// to build a new [`EditorMetdata`] from the file
|
||||
///
|
||||
/// # errors
|
||||
/// - [`DesktopExecError::IoError`] if reading the file fails
|
||||
/// - [`DesktopExecError::DecodeError`] if parsing the desktop entry fails
|
||||
/// - [`DesktopExecError::NoExec`] if the desktop entry does not have an Exec field
|
||||
fn try_new(desktop_file_path: PathBuf) -> Result<Self, DesktopExecError> {
|
||||
let input = std::fs::read_to_string(&desktop_file_path)?;
|
||||
|
||||
let entry = DesktopEntry::decode(&desktop_file_path, &input)?;
|
||||
|
||||
let Some(exec) = entry.exec() else {
|
||||
return Err(DesktopExecError::NoExec);
|
||||
};
|
||||
|
||||
// Doing all the calculations here to get owned versions of data fields,
|
||||
// so we can drop entry
|
||||
let exec = exec.to_string();
|
||||
let localized_name = entry.name(Some("en")).map(|x| x.to_string());
|
||||
let icon = entry.icon().map(str::to_string);
|
||||
|
||||
Ok(Self {
|
||||
desktop_file_path,
|
||||
exec,
|
||||
localized_name,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
|
||||
/// Common implementation of building a command
|
||||
///
|
||||
/// - Iterates over all characters in the Exec field, replacing field codes,
|
||||
/// to generate a new command string
|
||||
/// - Builds a new command that executes `sh -c <command_string>`
|
||||
///
|
||||
/// Field code replacement is handled by the `field_code_processor` callback.
|
||||
/// See [`Self::build_default_command`] and [`Self::process_field_code`]
|
||||
/// for examples of how these work.
|
||||
///
|
||||
/// ```ignore
|
||||
/// use std::path::PathBuf;
|
||||
/// use warp::util::file::external_editor::linux::EditorMetadata;
|
||||
///
|
||||
/// let desktop_file_path = PathBuf::from("/var/lib/snapd/desktop/applications/webstorm_webstorm.desktop");
|
||||
/// let metadata = EditorMetadata::try_new(desktop_file_path)?;
|
||||
///
|
||||
/// let my_file_path = PathBuf::from("~/foo.rs");
|
||||
///
|
||||
/// // This is identicial to metadata.build_default_command(my_file_path);
|
||||
/// let command = metadata.build_command(|me, acc, c| me.process_field_code(acc, c, my_file_path))?;
|
||||
///
|
||||
/// // If I want to do some custom stuff, I can use a modified field code processor
|
||||
/// let command = metadata.build_command(|me, acc, c| {
|
||||
/// match c {
|
||||
/// 'c' => acc += "foobar",
|
||||
/// c => me.process_field_code(acc, c, my_file_path),
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
fn build_command<T>(&self, field_code_processor: T) -> Result<Command, DesktopExecError>
|
||||
where
|
||||
T: Fn(&Self, &mut String, char),
|
||||
{
|
||||
let raw_exec = &self.exec;
|
||||
|
||||
let mut iter = raw_exec.chars();
|
||||
let mut processed_exec = String::new();
|
||||
while let Some(ch) = iter.next() {
|
||||
if ch != '%' {
|
||||
processed_exec.push(ch);
|
||||
continue;
|
||||
}
|
||||
let Some(next_char) = iter.next() else {
|
||||
return Err(DesktopExecError::MalformedFieldCode);
|
||||
};
|
||||
field_code_processor(self, &mut processed_exec, next_char);
|
||||
}
|
||||
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", &processed_exec]);
|
||||
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
/// The default handler for replacing field codes with values
|
||||
///
|
||||
/// Takes in a `field_code`, and handles appending replacement values
|
||||
/// to the passed in `processed_exec` string. Follows the standard
|
||||
/// here: https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html.
|
||||
/// Any fields like %f, %F, %u, and %U that rely on a file path use the `file_path`
|
||||
/// parameter.
|
||||
///
|
||||
/// Any errors or missing information (ex: %i with no Icon field, %U wiht a non-existent path)
|
||||
/// will fail silently, and result in nothing being appended to `processed_exec`
|
||||
fn process_field_code(&self, processed_exec: &mut String, field_code: char, file_path: &Path) {
|
||||
match field_code {
|
||||
// file path
|
||||
'f' | 'F' => *processed_exec += file_path.to_str().unwrap_or_default(),
|
||||
// URI
|
||||
'u' | 'U' => {
|
||||
// TODO(daprahamian): B/c we are using canonicalize, this will fail
|
||||
// if the file we are checking here does not actually exist. Also
|
||||
// it requires an fs check, which is not fun. In the future, it would
|
||||
// be nice to replace this with the pending std::path::absolute in
|
||||
// the future
|
||||
//
|
||||
// See https://github.com/rust-lang/rust/issues/92750
|
||||
if let Ok(absolute) = file_path.canonicalize() {
|
||||
if let Ok(file_url) = url::Url::from_file_path(absolute) {
|
||||
*processed_exec += file_url.as_str();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Localized Name
|
||||
'c' => {
|
||||
if let Some(localized_name) = self.localized_name.as_ref() {
|
||||
*processed_exec += localized_name;
|
||||
}
|
||||
}
|
||||
// Icon argument
|
||||
'i' => {
|
||||
if let Some(icon) = &self.icon {
|
||||
*processed_exec += "--icon ";
|
||||
*processed_exec += icon;
|
||||
}
|
||||
}
|
||||
// Path to the display file
|
||||
'k' => *processed_exec += self.desktop_file_path.to_str().unwrap_or_default(),
|
||||
// Just add the character
|
||||
other => processed_exec.push(other),
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds a command based on a FreeDesktop Desktop Entry Exec key.
|
||||
/// Will returns a `Command` object that invokes the Exec command,
|
||||
/// with all field codes replaced according to the standard.
|
||||
///
|
||||
/// The values for %f, %F, %u, and %U are all computed based on a single file
|
||||
/// path passed in. We do not support multiple paths at this time.
|
||||
///
|
||||
/// Any field code processing errors will fail silently
|
||||
///
|
||||
/// See https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s07.html
|
||||
fn build_default_command(&self, file_path: &Path) -> Result<Command, DesktopExecError> {
|
||||
self.build_command(|me, acc, c| me.process_field_code(acc, c, file_path))
|
||||
}
|
||||
|
||||
/// A variant of [`Self::build_default_command`] for jetbrains IDEs
|
||||
///
|
||||
/// Works the same, except that for %f, %F, %u, and %U field codes.
|
||||
/// When adding a file or URL, additional CLI flags are injected to specify
|
||||
/// line and column number if available.
|
||||
///
|
||||
/// NOTE: This is a non-standard behavior according to the .desktop specification.
|
||||
/// Any time we use this, it should be manually tested to verify that it works properly.
|
||||
fn build_jetbrains_command(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> Result<Command, DesktopExecError> {
|
||||
self.build_command(|me, acc, field_code| match field_code {
|
||||
'f' | 'F' | 'u' | 'U' => {
|
||||
if let Some(file_path) = file_path.to_str() {
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
*acc += &format!("--line {} ", line_column_number.line_num);
|
||||
if let Some(column_num) = line_column_number.column_num {
|
||||
*acc += &format!("--column {column_num} ");
|
||||
}
|
||||
}
|
||||
*acc += file_path;
|
||||
}
|
||||
}
|
||||
other => me.process_field_code(acc, other, file_path),
|
||||
})
|
||||
}
|
||||
/// A variant of [`Self::build_default_command`] for sublime
|
||||
///
|
||||
/// Works the same, except that for %f, %F, %u, and %U field codes.
|
||||
/// When adding a file or URL, the file name is appended with the line and column number if available.
|
||||
///
|
||||
/// NOTE: This is a non-standard behavior according to the .desktop specification.
|
||||
/// Any time we use this, it should be manually tested to verify that it works properly.
|
||||
fn build_sublime_command(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> Result<Command, DesktopExecError> {
|
||||
self.build_command(|me, acc, field_code| match field_code {
|
||||
'f' | 'F' | 'u' | 'U' => {
|
||||
if let Some(file_path) = file_path.to_str() {
|
||||
*acc += file_path;
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
*acc += &format!(":{}", line_column_number.line_num);
|
||||
if let Some(column_num) = line_column_number.column_num {
|
||||
*acc += &format!(":{column_num}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other => me.process_field_code(acc, other, file_path),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the given file in the specified editor.
|
||||
///
|
||||
/// If `line_column_number` is `Some`, the file will be opened with the cursor
|
||||
/// at the given location (if supported by the editor).
|
||||
///
|
||||
/// If with_editor is `None`, we attempt to compute the default editor for the
|
||||
/// given file type, and open the file there.
|
||||
pub fn open_file_path_with_line_and_col(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
with_editor: Option<Editor>,
|
||||
full_path: &Path,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
if full_path.is_file() {
|
||||
let with_editor = with_editor.or_else(|| get_app_for_file_from_mime(full_path));
|
||||
if let Some(editor) = with_editor {
|
||||
if let Some(mut command) = editor.command(full_path, line_column_number) {
|
||||
if let Err(err) = command.spawn() {
|
||||
log::error!("Error launching {editor:?}: {err:#}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.open_file_path(full_path);
|
||||
}
|
||||
|
||||
/// Attempt to match a file with an existing editor based on Mime type
|
||||
///
|
||||
/// Calls xdg-mime to first find the mime type of a file, and then find
|
||||
/// the xdg default app for that file. We then check against existing
|
||||
/// loaded editors to see if we have support for that file.
|
||||
///
|
||||
/// Used so that if xdg-open will work on a file we already know about,
|
||||
/// we can use line and col numbers.
|
||||
fn get_app_for_file_from_mime(path: &Path) -> Option<Editor> {
|
||||
let mime_type = String::from_utf8(
|
||||
Command::new("xdg-mime")
|
||||
.arg("query")
|
||||
.arg("filetype")
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?
|
||||
.stdout,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let default_app = String::from_utf8(
|
||||
Command::new("xdg-mime")
|
||||
.args(["query", "default", mime_type.trim()])
|
||||
.output()
|
||||
.ok()?
|
||||
.stdout,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let app_id = default_app.trim().replace(".desktop", "");
|
||||
|
||||
get_editor_by_app_id(compute_editors_by_id(), app_id.as_str())
|
||||
}
|
||||
|
||||
static EDITORS_BY_ID: OnceLock<HashMap<&'static str, Editor>> = OnceLock::new();
|
||||
// Compute a map from app ID to `Editor` for all supported editors.
|
||||
fn compute_editors_by_id() -> &'static HashMap<&'static str, Editor> {
|
||||
EDITORS_BY_ID.get_or_init(|| {
|
||||
let mut editors_by_id = HashMap::new();
|
||||
for editor in enum_iterator::all::<Editor>() {
|
||||
if let Some(app_ids) = editor.app_ids() {
|
||||
for app_id in app_ids.iter() {
|
||||
editors_by_id.insert(*app_id, editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
editors_by_id
|
||||
})
|
||||
}
|
||||
|
||||
/// Looks up the editor given an app_id
|
||||
///
|
||||
/// Special case for snap desktop files. snap desktop files follow XDG Desktop Entry
|
||||
/// Specification 1.1, which predates standard naming conventions. We are winding up
|
||||
/// with names of the format:
|
||||
///
|
||||
/// {snap-package-id}_{app-id}.desktop
|
||||
/// Examples include "code_code.desktop", "code-insiders_code-insiders.desktop",
|
||||
/// "code_code-url-handler.desktop", etc. So we check for the _ and use whatever follows.
|
||||
///
|
||||
/// See: https://snapcraft.io/docs/desktop-menu-support
|
||||
/// See: https://forum.snapcraft.io/t/overriding-desktop-files-on-ubuntu-snaps/6599/4
|
||||
fn get_editor_by_app_id(
|
||||
editors_by_id: &HashMap<&'static str, Editor>,
|
||||
app_id: &str,
|
||||
) -> Option<Editor> {
|
||||
editors_by_id
|
||||
.get(app_id)
|
||||
.or_else(|| {
|
||||
let (_, app_id) = app_id.split_once('_')?;
|
||||
|
||||
if app_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
editors_by_id.get(app_id)
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Computes the list of installed editors.
|
||||
fn compute_installed_editors() -> HashMap<Editor, EditorMetadata> {
|
||||
let editors_by_id = compute_editors_by_id();
|
||||
|
||||
// Iterate through the .desktop files in the places they are typically
|
||||
// installed and see if the app ID (file stem) matches a supported
|
||||
// editor.
|
||||
let mut editors = HashMap::new();
|
||||
for path in freedesktop_desktop_entry::Iter::new(freedesktop_desktop_entry::default_paths()) {
|
||||
let Some(app_id) = path.file_stem().and_then(OsStr::to_str) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(editor) = get_editor_by_app_id(editors_by_id, app_id) {
|
||||
match EditorMetadata::try_new(path) {
|
||||
Ok(metadata) => {
|
||||
editors.insert(editor, metadata);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to load editor config: {e:#}"),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
editors
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
fn app_ids(&self) -> Option<&[&'static str]> {
|
||||
use Editor::*;
|
||||
match self {
|
||||
AndroidStudio => Some(&["android-studio", "jetbrains-studio"]),
|
||||
CLion => Some(&["clion", "jetbrains-clion"]),
|
||||
DataGrip => Some(&["datagrip", "jetbrains-datagrip"]),
|
||||
DataSpell => Some(&["dataspell", "jetbrains-dataspell"]),
|
||||
IntelliJ => Some(&["jetbrains-idea", "intellij-idea-ultimate"]),
|
||||
IntelliJCE => Some(&["jetbrains-idea-ce", "intellij-idea-community"]),
|
||||
GoLand => Some(&["goland", "jetbrains-goland"]),
|
||||
PhpStorm => Some(&["phpstorm", "jetbrains-phpstorm"]),
|
||||
PyCharm => Some(&["pycharm-professional", "jetbrains-pycharm"]),
|
||||
PyCharmCE => Some(&["pycharm-community", "jetbrains-pycharm-ce"]),
|
||||
Rider => Some(&["rider", "jetbrains-rider"]),
|
||||
RubyMine => Some(&["rubymine", "jetbrains-rubymine"]),
|
||||
Sublime => Some(&["sublime-text_subl", "sublime_text"]),
|
||||
VSCode => Some(&["code"]),
|
||||
VSCodeInsiders => Some(&["code-insiders"]),
|
||||
WebStorm => Some(&["webstorm", "jetbrains-webstorm"]),
|
||||
Windsurf => Some(&["windsurf"]),
|
||||
Zed => Some(&["dev.zed.Zed"]),
|
||||
ZedPreview => Some(&["dev.zed.Zed-Preview"]), // both Zed stable and preview use the same binary on Linux
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_editors(&self) -> &HashMap<Editor, EditorMetadata> {
|
||||
INSTALLED_EDITOR_METADATA.get_or_init(compute_installed_editors)
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, _ctx: &mut AppContext) -> bool {
|
||||
use Editor::*;
|
||||
match self {
|
||||
// For Zed editors on Linux, we need to detect which channel is installed by checking both
|
||||
// the .desktop file and the actual binary location
|
||||
Zed | ZedPreview => {
|
||||
// First check if .desktop file exists
|
||||
if !self.installed_editors().contains_key(self) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then verify the correct binary exists in its installation path
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let binary_path = match self {
|
||||
Zed => format!("{home}/.local/zed.app/bin/zed"),
|
||||
ZedPreview => format!("{home}/.local/zed-preview.app/bin/zed"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
std::path::Path::new(&binary_path).exists()
|
||||
}
|
||||
// For all other editors, just check the desktop file
|
||||
_ => self.installed_editors().contains_key(self),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_metadata(&self) -> Option<&EditorMetadata> {
|
||||
self.installed_editors().get(self)
|
||||
}
|
||||
|
||||
fn command(
|
||||
&self,
|
||||
file_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> Option<Command> {
|
||||
use Editor::*;
|
||||
match self {
|
||||
VSCode => {
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
let mut command = Command::new("xdg-open");
|
||||
command.arg(format!("vscode://file{}{suffix}", file_path.display()));
|
||||
Some(command)
|
||||
}
|
||||
VSCodeInsiders => {
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
let mut command = Command::new("xdg-open");
|
||||
command.arg(format!(
|
||||
"vscode-insiders://file{}{suffix}",
|
||||
file_path.display()
|
||||
));
|
||||
Some(command)
|
||||
}
|
||||
Windsurf => {
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
let mut command = Command::new("xdg-open");
|
||||
command.arg(format!("windsurf://file{}{suffix}", file_path.display()));
|
||||
Some(command)
|
||||
}
|
||||
AndroidStudio | CLion | CLionCE | DataGrip | DataSpell | GoLand | IntelliJ
|
||||
| IntelliJCE | PhpStorm | PyCharm | PyCharmCE | Rider | RubyMine | WebStorm => {
|
||||
match self.get_metadata() {
|
||||
Some(metadata) => {
|
||||
match metadata.build_jetbrains_command(file_path, line_column_number) {
|
||||
Ok(command) => Some(command),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to build editor open command: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
Sublime => match self.get_metadata() {
|
||||
Some(metadata) => {
|
||||
log::info!("Opening at {file_path:?} + {line_column_number:?}");
|
||||
match metadata.build_sublime_command(file_path, line_column_number) {
|
||||
Ok(command) => {
|
||||
log::info!("Command: {command:?}");
|
||||
Some(command)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to build editor open command: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
Zed | ZedPreview => {
|
||||
// Get the correct binary path based on which editor was selected
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let binary_path = match self {
|
||||
Zed => format!("{home}/.local/zed.app/bin/zed"),
|
||||
ZedPreview => format!("{home}/.local/zed-preview.app/bin/zed"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Format the file path with line/column if provided
|
||||
let file_path_str = file_path.display().to_string();
|
||||
let position = if let Some(line_col) = line_column_number {
|
||||
if let Some(col) = line_col.column_num {
|
||||
format!("{}:{}:{}", file_path_str, line_col.line_num, col)
|
||||
} else {
|
||||
format!("{}:{}", file_path_str, line_col.line_num)
|
||||
}
|
||||
} else {
|
||||
file_path_str
|
||||
};
|
||||
|
||||
// Build command using setsid for proper detachment
|
||||
let mut command = Command::new("/usr/bin/setsid");
|
||||
command.args([
|
||||
"-f", // Fork to background
|
||||
&binary_path, // The specific Zed binary to run
|
||||
&position, // File path with optional line/column
|
||||
]);
|
||||
|
||||
// Redirect all stdio to null
|
||||
command.stdin(std::process::Stdio::null());
|
||||
command.stdout(std::process::Stdio::null());
|
||||
command.stderr(std::process::Stdio::null());
|
||||
Some(command)
|
||||
}
|
||||
_ => match self.get_metadata() {
|
||||
Some(metadata) => match metadata.build_default_command(file_path) {
|
||||
Ok(command) => Some(command),
|
||||
Err(err) => {
|
||||
log::error!("Failed to build editor open command: {err:#}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum DesktopExecError {
|
||||
#[error("i/o error {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("decode error {0}")]
|
||||
DecodeError(#[from] freedesktop_desktop_entry::DecodeError),
|
||||
|
||||
#[error("Attempted to create command for desktop entry with no exec field")]
|
||||
NoExec,
|
||||
|
||||
#[error("Malformed exec call: non-terminated field code")]
|
||||
MalformedFieldCode,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "linux_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,387 @@
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
|
||||
use super::{DesktopExecError, EditorMetadata};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_files(tag: &str, contents: &str, cb: impl FnOnce(PathBuf, PathBuf) -> anyhow::Result<()>) {
|
||||
use crate::test_util::{Stub, VirtualFS};
|
||||
|
||||
VirtualFS::test(tag, |dirs, mut sandbox| {
|
||||
sandbox.with_files(vec![
|
||||
Stub::FileWithContent("bar.desktop", contents),
|
||||
Stub::EmptyFile("foo.txt"),
|
||||
]);
|
||||
|
||||
let desktop_file_path = dirs.tests().join("bar.desktop");
|
||||
let content_file_path = dirs.tests().join("foo.txt");
|
||||
|
||||
match cb(desktop_file_path, content_file_path) {
|
||||
Ok(_) => {}
|
||||
Err(err) => panic!("{err:?}"),
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_exec_command_errors() {
|
||||
with_files(
|
||||
"test_missing_exec_command_errors",
|
||||
"",
|
||||
|desktop, _content| {
|
||||
let result = EditorMetadata::try_new(desktop);
|
||||
|
||||
assert!(matches!(result, Err(DesktopExecError::NoExec)));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exec_ending_on_percent_fails() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo "hello world" %
|
||||
"#;
|
||||
with_files(
|
||||
"test_exec_ending_on_percent_fails",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&content);
|
||||
assert!(matches!(result, Err(DesktopExecError::MalformedFieldCode)));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_exec_no_field_codes() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo "hello world"
|
||||
"#;
|
||||
with_files(
|
||||
"test_basic_exec_no_field_codes",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&content);
|
||||
assert!(result.is_ok());
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "sh");
|
||||
assert_eq!(
|
||||
cmd.get_args().collect::<Vec<_>>(),
|
||||
["-c", "echo \"hello world\""]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_path_substitution() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=cat %f
|
||||
"#;
|
||||
with_files("test_file_path_substitution", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_name = content.display().to_string();
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", format!("cat {file_name}").as_str()]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=cat %F
|
||||
"#;
|
||||
with_files("test_file_path_substitution", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_name = content.display().to_string();
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", format!("cat {file_name}").as_str()]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_url_substitution() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=open %u
|
||||
"#;
|
||||
with_files("test_file_url_substitution", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_name = content.display().to_string();
|
||||
let expected_file_uri = format!("file://{file_name}");
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("open {expected_file_uri}")]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=open %U
|
||||
"#;
|
||||
with_files("test_file_url_substitution", data, |desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_name = content.display().to_string();
|
||||
let expected_file_uri = format!("file://{file_name}");
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("open {expected_file_uri}")]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remaining_substitutions() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=echo %c && echo %i && echo %k && echo %%
|
||||
Name=Warp Test Application
|
||||
Icon=/foo/bar/icon.png
|
||||
"#;
|
||||
with_files("test_remaining_substitutions", data, |desktop, content| {
|
||||
let desktop_file_path = desktop.display().to_string();
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let result = metadata.build_default_command(&content);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("echo Warp Test Application && echo --icon /foo/bar/icon.png && echo {desktop_file_path} && echo %")]
|
||||
);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jetbrains_command_no_line_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/phpstorm %f
|
||||
"#;
|
||||
|
||||
with_files(
|
||||
"test_jetbrains_command_no_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_jetbrains_command(&content, None);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/phpstorm {file_path}")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jetbrains_command_line_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/phpstorm %f
|
||||
"#;
|
||||
|
||||
with_files(
|
||||
"test_jetbrains_command_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_jetbrains_command(
|
||||
&content,
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: None,
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/phpstorm --line 42 {file_path}")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jetbrains_command_line_and_col_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/phpstorm %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_jetbrains_command_line_and_col_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_jetbrains_command(
|
||||
&content,
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: Some(25),
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
[
|
||||
"-c",
|
||||
&format!("/snap/bin/phpstorm --line 42 --column 25 {file_path}")
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sublime_command_no_line_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/subl %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_sublime_command_no_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result: Result<command::blocking::Command, DesktopExecError> =
|
||||
metadata.build_sublime_command(&content, None);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sublime_command_line_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/subl %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_sublime_command_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_sublime_command(
|
||||
&content,
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: None,
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}:42")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sublime_command_line_and_col_numbers() {
|
||||
let data = r#"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Exec=/snap/bin/subl %f
|
||||
"#;
|
||||
with_files(
|
||||
"test_sublime_command_line_numbers",
|
||||
data,
|
||||
|desktop, content| {
|
||||
let metadata = EditorMetadata::try_new(desktop)?;
|
||||
let file_path = content.display().to_string();
|
||||
let result = metadata.build_sublime_command(
|
||||
&content,
|
||||
Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: Some(25),
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap().get_args().collect::<Vec<_>>(),
|
||||
["-c", &format!("/snap/bin/subl {file_path}:42:25")]
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
#![allow(deprecated)]
|
||||
|
||||
use instant::Instant;
|
||||
use std::slice;
|
||||
use std::{fmt::Write, path::Path};
|
||||
|
||||
use cocoa::{
|
||||
base::{id, nil},
|
||||
foundation::{NSAutoreleasePool, NSString},
|
||||
};
|
||||
use command::r#async::Command;
|
||||
use warpui::{platform::mac::make_nsstring, ApplicationBundleInfo};
|
||||
|
||||
use super::*;
|
||||
|
||||
// Functions implemented in objC files.
|
||||
extern "C" {
|
||||
fn get_default_app_bundle_for_file(file_path: id) -> id;
|
||||
}
|
||||
|
||||
/// The exeutable we use to launch the editor.
|
||||
#[derive(Debug)]
|
||||
pub enum OpenFileInEditorMethod {
|
||||
// A custom binary (e.g. the code CLI tool for VSCode).
|
||||
Binary(String),
|
||||
// Default application bundle from the app registration info in Cocoa.
|
||||
FromApplicationBundleInfo,
|
||||
// Use /usr/bin/open to open the file directly using the Editor's registered URL protocol.
|
||||
// The optional bundle identifier parameter allows for two different use cases:
|
||||
//
|
||||
// 1. AppUrl(None) - Opens the URL directly with the system's default handler
|
||||
// Example: `open vscode://file/hello.rs`
|
||||
// Used by editors like VSCode that rely on URL scheme registration
|
||||
//
|
||||
// 2. AppUrl(Some(bundle_id)) - Opens the URL with a specific application bundle
|
||||
// Example: `open -b dev.zed.Zed zed://file/hello.rs`
|
||||
// Used by editors like Zed that need explicit bundle specification
|
||||
AppUrl(Option<&'static str>),
|
||||
}
|
||||
|
||||
impl OpenFileInEditorMethod {
|
||||
pub fn command(&self, application_bundle_info: ApplicationBundleInfo) -> Command {
|
||||
let mut open_command = Command::new("/usr/bin/open");
|
||||
|
||||
match self {
|
||||
OpenFileInEditorMethod::Binary(binary_path)
|
||||
if application_bundle_info.path.join(binary_path).exists() =>
|
||||
{
|
||||
Command::new(application_bundle_info.path.join(binary_path))
|
||||
}
|
||||
OpenFileInEditorMethod::AppUrl(_) => open_command,
|
||||
_ => {
|
||||
open_command.arg("-a").arg(application_bundle_info.path);
|
||||
open_command
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Editor {
|
||||
const VSCODE_IDENTIFIER: &'a str = "com.microsoft.VSCode";
|
||||
const VSCODE_INSIDERS_IDENTIFIER: &'a str = "com.microsoft.VSCodeInsiders";
|
||||
const PYCHARM_CE_IDENTIFIER: &'a str = "com.jetbrains.pycharm.ce";
|
||||
const INTELLIJ_CE_IDENTIFIER: &'a str = "com.jetbrains.intellij.ce";
|
||||
const CLION_CE_IDENTIFIER: &'a str = "com.jetbrains.clion.ce";
|
||||
|
||||
/// Bundle identifier for the Rust Rover Preview build.
|
||||
const RUST_ROVER_PREVIEW_IDENTIFIER: &'a str = "com.jetbrains.rustrover-EAP";
|
||||
|
||||
/// Bundle identifier for the Rust Rover build.
|
||||
const RUST_ROVER_IDENTIFIER: &'a str = "com.jetbrains.rustrover";
|
||||
|
||||
const PYCHARM_IDENTIFIER: &'a str = "com.jetbrains.PyCharm";
|
||||
const INTELLIJ_IDENTIFIER: &'a str = "com.jetbrains.intellij";
|
||||
const CLION_IDENTIFIER: &'a str = "com.jetbrains.CLion";
|
||||
const PHPSTORM_IDENTIFIER: &'a str = "com.jetbrains.PhpStorm";
|
||||
const RUBYMINE_IDENTIFIER: &'a str = "com.jetbrains.RubyMine";
|
||||
const WEBSTORM_IDENTIFIER: &'a str = "com.jetbrains.WebStorm";
|
||||
const SUBLIME_4_IDENTIFIER: &'a str = "com.sublimetext.4";
|
||||
const SUBLIME_3_IDENTIFIER: &'a str = "com.sublimetext.3";
|
||||
const SUBLIME_2_IDENTIFIER: &'a str = "com.sublimetext.2";
|
||||
const ATOM_IDENTIFIER: &'a str = "com.github.atom";
|
||||
const ZED_IDENTIFIER: &'a str = "dev.zed.Zed";
|
||||
const ZED_PREVIEW_IDENTIFIER: &'a str = "dev.zed.Zed-Preview";
|
||||
const GOLAND_IDENTIFIER: &'a str = "com.jetbrains.goland";
|
||||
const RIDER_IDENTIFIER: &'a str = "com.jetbrains.rider";
|
||||
const DATASPELL_IDENTIFIER: &'a str = "com.jetbrains.dataspell";
|
||||
const DATAGRIP_IDENTIFIER: &'a str = "com.jetbrains.datagrip";
|
||||
const ANDROID_STUDIO_IDENTIFIER: &'a str = "com.google.android.studio";
|
||||
const CURSOR_IDENTIFIER: &'a str = "com.todesktop.230313mzl4w4u92";
|
||||
const WINDSURF_IDENTIFIER: &'a str = "com.exafunction.windsurf";
|
||||
|
||||
pub fn new_from_identifier(app_identifier: &str) -> Option<Self> {
|
||||
match app_identifier {
|
||||
Editor::VSCODE_IDENTIFIER => Some(Editor::VSCode),
|
||||
Editor::VSCODE_INSIDERS_IDENTIFIER => Some(Editor::VSCodeInsiders),
|
||||
Editor::PYCHARM_CE_IDENTIFIER => Some(Editor::PyCharmCE),
|
||||
Editor::PYCHARM_IDENTIFIER => Some(Editor::PyCharm),
|
||||
Editor::INTELLIJ_CE_IDENTIFIER => Some(Editor::IntelliJCE),
|
||||
Editor::INTELLIJ_IDENTIFIER => Some(Editor::IntelliJ),
|
||||
Editor::CLION_IDENTIFIER => Some(Editor::CLion),
|
||||
Editor::CLION_CE_IDENTIFIER => Some(Editor::CLionCE),
|
||||
Editor::ATOM_IDENTIFIER => Some(Editor::Atom),
|
||||
Editor::SUBLIME_4_IDENTIFIER => Some(Editor::Sublime4),
|
||||
Editor::SUBLIME_3_IDENTIFIER => Some(Editor::Sublime3),
|
||||
Editor::SUBLIME_2_IDENTIFIER => Some(Editor::Sublime2),
|
||||
Editor::ZED_IDENTIFIER => Some(Editor::Zed),
|
||||
Editor::ZED_PREVIEW_IDENTIFIER => Some(Editor::ZedPreview),
|
||||
Editor::GOLAND_IDENTIFIER => Some(Editor::GoLand),
|
||||
Editor::RIDER_IDENTIFIER => Some(Editor::Rider),
|
||||
Editor::DATASPELL_IDENTIFIER => Some(Editor::DataSpell),
|
||||
Editor::DATAGRIP_IDENTIFIER => Some(Editor::DataGrip),
|
||||
Editor::ANDROID_STUDIO_IDENTIFIER => Some(Editor::AndroidStudio),
|
||||
Editor::CURSOR_IDENTIFIER => Some(Editor::Cursor),
|
||||
Editor::WINDSURF_IDENTIFIER => Some(Editor::Windsurf),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn application_bundle_info(
|
||||
&'a self,
|
||||
ctx: &'a mut AppContext,
|
||||
) -> Option<ApplicationBundleInfo<'a>> {
|
||||
ctx.application_bundle_info(match self {
|
||||
Editor::VSCode => Editor::VSCODE_IDENTIFIER,
|
||||
Editor::VSCodeInsiders => Editor::VSCODE_INSIDERS_IDENTIFIER,
|
||||
Editor::PyCharmCE => Editor::PYCHARM_CE_IDENTIFIER,
|
||||
Editor::PyCharm => Editor::PYCHARM_IDENTIFIER,
|
||||
Editor::IntelliJCE => Editor::INTELLIJ_CE_IDENTIFIER,
|
||||
Editor::IntelliJ => Editor::INTELLIJ_IDENTIFIER,
|
||||
Editor::CLionCE => Editor::CLION_CE_IDENTIFIER,
|
||||
Editor::CLion => Editor::CLION_IDENTIFIER,
|
||||
Editor::Sublime4 => Editor::SUBLIME_4_IDENTIFIER,
|
||||
Editor::Sublime3 => Editor::SUBLIME_3_IDENTIFIER,
|
||||
Editor::Sublime2 => Editor::SUBLIME_2_IDENTIFIER,
|
||||
Editor::Atom => Editor::ATOM_IDENTIFIER,
|
||||
Editor::PhpStorm => Editor::PHPSTORM_IDENTIFIER,
|
||||
Editor::WebStorm => Editor::WEBSTORM_IDENTIFIER,
|
||||
Editor::RubyMine => Editor::RUBYMINE_IDENTIFIER,
|
||||
Editor::Zed => Editor::ZED_IDENTIFIER,
|
||||
Editor::ZedPreview => Editor::ZED_PREVIEW_IDENTIFIER,
|
||||
Editor::GoLand => Editor::GOLAND_IDENTIFIER,
|
||||
Editor::Rider => Editor::RIDER_IDENTIFIER,
|
||||
Editor::DataSpell => Editor::DATASPELL_IDENTIFIER,
|
||||
Editor::DataGrip => Editor::DATAGRIP_IDENTIFIER,
|
||||
Editor::AndroidStudio => Editor::ANDROID_STUDIO_IDENTIFIER,
|
||||
Editor::Cursor => Editor::CURSOR_IDENTIFIER,
|
||||
Editor::RustRoverPreview => Editor::RUST_ROVER_PREVIEW_IDENTIFIER,
|
||||
Editor::RustRover => Editor::RUST_ROVER_IDENTIFIER,
|
||||
Editor::Windsurf => Editor::WINDSURF_IDENTIFIER,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, ctx: &mut AppContext) -> bool {
|
||||
self.application_bundle_info(ctx).is_some()
|
||||
}
|
||||
|
||||
fn command_executable_and_arguments(
|
||||
&self,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: &Path,
|
||||
) -> (OpenFileInEditorMethod, Vec<String>) {
|
||||
let full_path_with_line_column =
|
||||
Self::format_file_path_with_line_and_column(full_path, line_column_number);
|
||||
match self {
|
||||
Editor::VSCode => (
|
||||
OpenFileInEditorMethod::AppUrl(None),
|
||||
vec![format!("vscode://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::VSCodeInsiders => (
|
||||
OpenFileInEditorMethod::AppUrl(None),
|
||||
vec![format!(
|
||||
"vscode-insiders://file{}",
|
||||
full_path_with_line_column
|
||||
)],
|
||||
),
|
||||
Editor::Windsurf => (
|
||||
OpenFileInEditorMethod::AppUrl(None),
|
||||
vec![format!("windsurf://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::PyCharm | Editor::PyCharmCE => {
|
||||
Self::jetbrains_command("pycharm", line_column_number, full_path)
|
||||
}
|
||||
Editor::IntelliJ | Editor::IntelliJCE => {
|
||||
Self::jetbrains_command("idea", line_column_number, full_path)
|
||||
}
|
||||
Editor::CLion | Editor::CLionCE => {
|
||||
Self::jetbrains_command("clion", line_column_number, full_path)
|
||||
}
|
||||
Editor::RubyMine => Self::jetbrains_command("rubymine", line_column_number, full_path),
|
||||
Editor::PhpStorm => Self::jetbrains_command("phpstorm", line_column_number, full_path),
|
||||
Editor::WebStorm => Self::jetbrains_command("webstorm", line_column_number, full_path),
|
||||
Editor::Sublime4 | Editor::Sublime3 | Editor::Sublime2 => (
|
||||
OpenFileInEditorMethod::Binary("Contents/SharedSupport/bin/subl".to_string()),
|
||||
vec![full_path_with_line_column],
|
||||
),
|
||||
Editor::Atom => (
|
||||
OpenFileInEditorMethod::FromApplicationBundleInfo,
|
||||
vec![full_path_with_line_column],
|
||||
),
|
||||
Editor::Zed => (
|
||||
OpenFileInEditorMethod::AppUrl(Some(Editor::ZED_IDENTIFIER)),
|
||||
vec![format!("zed://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::ZedPreview => (
|
||||
OpenFileInEditorMethod::AppUrl(Some(Editor::ZED_PREVIEW_IDENTIFIER)),
|
||||
vec![format!("zed://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::GoLand => Self::jetbrains_command("goland", line_column_number, full_path),
|
||||
Editor::Rider => Self::jetbrains_command("rider", line_column_number, full_path),
|
||||
Editor::DataSpell => {
|
||||
Self::jetbrains_command("dataspell", line_column_number, full_path)
|
||||
}
|
||||
Editor::DataGrip => Self::jetbrains_command("datagrip", line_column_number, full_path),
|
||||
Editor::AndroidStudio => {
|
||||
Self::jetbrains_command("studio", line_column_number, full_path)
|
||||
}
|
||||
Editor::Cursor => (
|
||||
OpenFileInEditorMethod::AppUrl(None),
|
||||
vec![format!("cursor://file{}", full_path_with_line_column)],
|
||||
),
|
||||
Editor::RustRoverPreview | Editor::RustRover => {
|
||||
Self::jetbrains_command("rustrover", line_column_number, full_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn jetbrains_command(
|
||||
cli_name: &str,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: &Path,
|
||||
) -> (OpenFileInEditorMethod, Vec<String>) {
|
||||
let full_path = full_path.to_str().expect("full path exists").to_string();
|
||||
(
|
||||
OpenFileInEditorMethod::Binary(format!("Contents/MacOS/{cli_name}")),
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
vec![
|
||||
"--line".to_string(),
|
||||
line_column_number.line_num.to_string(),
|
||||
full_path,
|
||||
]
|
||||
} else {
|
||||
vec![full_path]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
&self,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: &Path,
|
||||
ctx: &mut AppContext,
|
||||
) -> bool {
|
||||
let Some(application_bundle_info) = self.application_bundle_info(ctx) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let (executable, arguments) =
|
||||
self.command_executable_and_arguments(line_column_number, full_path);
|
||||
|
||||
// Build the command based on the executable type:
|
||||
// - For AppUrl(Some(bundle_id)): Use `open -b bundle_id` to explicitly specify the app
|
||||
// - For AppUrl(None): Use plain `open` command to let the system handle the URL scheme
|
||||
// - For other methods: Use the standard command creation logic
|
||||
let mut command = match &executable {
|
||||
OpenFileInEditorMethod::AppUrl(Some(bundle_id)) => {
|
||||
let mut cmd = Command::new("/usr/bin/open");
|
||||
cmd.arg("-b").arg(bundle_id);
|
||||
cmd
|
||||
}
|
||||
_ => executable.command(application_bundle_info),
|
||||
};
|
||||
|
||||
match command.args(arguments).spawn() {
|
||||
Ok(mut child) => {
|
||||
ctx.background_executor()
|
||||
.spawn(async move {
|
||||
let now = Instant::now();
|
||||
match child.status().await {
|
||||
Ok(exit_code) => {
|
||||
log::debug!(
|
||||
"process exited after {}ms with exit code: {}",
|
||||
now.elapsed().as_millis(),
|
||||
exit_code
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("unable to await process {err:?}");
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
log::info!("Successfully launched {self:?}.");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error launching {self:?} {e:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given the line column number and the path, format into "path:line:column".
|
||||
fn format_file_path_with_line_and_column(
|
||||
full_path: &Path,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
) -> String {
|
||||
let mut full_path_with_line_column = full_path.to_string_lossy().to_string();
|
||||
|
||||
if let Some(line_column_number) = line_column_number {
|
||||
let _ = write!(
|
||||
&mut full_path_with_line_column,
|
||||
":{}",
|
||||
line_column_number.line_num
|
||||
);
|
||||
|
||||
if let Some(column_num) = line_column_number.column_num {
|
||||
let _ = write!(&mut full_path_with_line_column, ":{column_num}");
|
||||
}
|
||||
}
|
||||
|
||||
full_path_with_line_column
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_file_path_with_line_and_col(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
with_editor: Option<Editor>,
|
||||
full_path: &Path,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
if full_path.is_file() {
|
||||
let editor = if with_editor.is_some_and(|editor| editor.is_installed(ctx)) {
|
||||
with_editor
|
||||
} else {
|
||||
let app_bundle_id = unsafe { default_app_to_open_path(full_path) };
|
||||
app_bundle_id
|
||||
.as_deref()
|
||||
.and_then(Editor::new_from_identifier)
|
||||
};
|
||||
|
||||
if let Some(editor) = editor {
|
||||
if editor.open(line_column_number, full_path, ctx) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.open_file_path(full_path);
|
||||
}
|
||||
|
||||
// Get the Mac default app for opening the file path.
|
||||
//
|
||||
// The NSString returned by `-[NSBundle bundleIdentifier]` is autoreleased by
|
||||
// Cocoa. We wrap the call in a local pool so the autoreleased string (and the
|
||||
// one we pass in via `make_nsstring`) are drained before we return, and copy
|
||||
// the UTF-8 bytes out into an owned `String` so no dangling pointer escapes.
|
||||
unsafe fn default_app_to_open_path(file_path: &Path) -> Option<String> {
|
||||
let pool = NSAutoreleasePool::new(nil);
|
||||
let bundle_id = get_default_app_bundle_for_file(make_nsstring(file_path.to_string_lossy()));
|
||||
let result = if bundle_id == nil {
|
||||
None
|
||||
} else {
|
||||
let cstr = bundle_id.UTF8String() as *const u8;
|
||||
std::str::from_utf8(slice::from_raw_parts(cstr, bundle_id.len()))
|
||||
.ok()
|
||||
.map(ToOwned::to_owned)
|
||||
};
|
||||
pool.drain();
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
pub mod settings;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::EditorChoice;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
pub use self::settings::{EditorLayout, EditorSettings};
|
||||
|
||||
pub const SUPPORTED_EDITORS: &[Editor] = &[
|
||||
Editor::VSCode,
|
||||
Editor::VSCodeInsiders,
|
||||
Editor::Atom,
|
||||
Editor::CLion,
|
||||
Editor::CLionCE,
|
||||
Editor::RustRoverPreview,
|
||||
Editor::RustRover,
|
||||
Editor::IntelliJ,
|
||||
Editor::IntelliJCE,
|
||||
Editor::PyCharm,
|
||||
Editor::PyCharmCE,
|
||||
Editor::WebStorm,
|
||||
Editor::PhpStorm,
|
||||
Editor::RubyMine,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// On Linux, all versions of sublime use the same app-ids, so
|
||||
// we only have one entry
|
||||
Editor::Sublime,
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime2,
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime3,
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime4,
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
// Zed is available on macos and linux
|
||||
Editor::Zed,
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
// Zed Preview is available on macos and linux
|
||||
Editor::ZedPreview,
|
||||
Editor::GoLand,
|
||||
Editor::Rider,
|
||||
Editor::DataSpell,
|
||||
Editor::DataGrip,
|
||||
Editor::AndroidStudio,
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
// Cursor *can* run on linux, but does not have a .desktop file
|
||||
Editor::Cursor,
|
||||
Editor::Windsurf,
|
||||
];
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
enum_iterator::Sequence,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "An external code editor.", rename_all = "snake_case")]
|
||||
pub enum Editor {
|
||||
VSCode,
|
||||
VSCodeInsiders,
|
||||
PyCharm,
|
||||
PyCharmCE,
|
||||
IntelliJ,
|
||||
IntelliJCE,
|
||||
CLion,
|
||||
CLionCE,
|
||||
RustRoverPreview,
|
||||
RustRover,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
Sublime,
|
||||
#[cfg(target_os = "macos")]
|
||||
Sublime4,
|
||||
#[cfg(target_os = "macos")]
|
||||
Sublime3,
|
||||
#[cfg(target_os = "macos")]
|
||||
Sublime2,
|
||||
Atom,
|
||||
WebStorm,
|
||||
PhpStorm,
|
||||
RubyMine,
|
||||
Zed,
|
||||
ZedPreview,
|
||||
GoLand,
|
||||
Rider,
|
||||
DataSpell,
|
||||
DataGrip,
|
||||
AndroidStudio,
|
||||
Cursor,
|
||||
Windsurf,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Editor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Editor::VSCode => "VSCode",
|
||||
Editor::VSCodeInsiders => "VSCode Insiders",
|
||||
Editor::PyCharm => "PyCharm",
|
||||
Editor::PyCharmCE => "PyCharm Community Edition",
|
||||
Editor::IntelliJ => "IntelliJ",
|
||||
Editor::IntelliJCE => "IntelliJ Community Edition",
|
||||
Editor::CLion => "CLion",
|
||||
Editor::CLionCE => "CLion Community Edition",
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
Editor::Sublime => "Sublime",
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime4 => "Sublime 4",
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime3 => "Sublime 3",
|
||||
#[cfg(target_os = "macos")]
|
||||
Editor::Sublime2 => "Sublime 2",
|
||||
Editor::Atom => "Atom",
|
||||
Editor::WebStorm => "WebStorm",
|
||||
Editor::PhpStorm => "PhpStorm",
|
||||
Editor::RubyMine => "RubyMine",
|
||||
Editor::Zed => "Zed",
|
||||
Editor::ZedPreview => "Zed Preview",
|
||||
Editor::GoLand => "GoLand",
|
||||
Editor::Rider => "Rider",
|
||||
Editor::DataSpell => "DataSpell",
|
||||
Editor::DataGrip => "DataGrip",
|
||||
Editor::AndroidStudio => "Android Studio",
|
||||
Editor::Cursor => "Cursor",
|
||||
Editor::RustRoverPreview => "Rust Rover (Preview)",
|
||||
Editor::RustRover => "Rust Rover",
|
||||
Editor::Windsurf => "Windsurf",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Editor {
|
||||
type Error = ();
|
||||
|
||||
/// Maps an editor command name to a supported Editor enum if available.
|
||||
/// This allows us to use existing editor integrations instead of shell commands when possible.
|
||||
fn try_from(editor_name: &str) -> Result<Self, Self::Error> {
|
||||
let editor_base = std::path::Path::new(editor_name)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(editor_name)
|
||||
.to_lowercase();
|
||||
|
||||
match editor_base.as_str() {
|
||||
"code" => Ok(Editor::VSCode),
|
||||
"code-insiders" => Ok(Editor::VSCodeInsiders),
|
||||
"zed" => Ok(Editor::Zed),
|
||||
"zed-preview" => Ok(Editor::ZedPreview),
|
||||
"cursor" => Ok(Editor::Cursor),
|
||||
"windsurf" => Ok(Editor::Windsurf),
|
||||
"clion" => Ok(Editor::CLion),
|
||||
"pycharm" => Ok(Editor::PyCharm),
|
||||
"pycharm-ce" => Ok(Editor::PyCharmCE),
|
||||
"intellij" => Ok(Editor::IntelliJ),
|
||||
"intellij-ce" => Ok(Editor::IntelliJCE),
|
||||
"webstorm" => Ok(Editor::WebStorm),
|
||||
"phpstorm" => Ok(Editor::PhpStorm),
|
||||
"rubymine" => Ok(Editor::RubyMine),
|
||||
"goland" => Ok(Editor::GoLand),
|
||||
"rider" => Ok(Editor::Rider),
|
||||
"datagrip" => Ok(Editor::DataGrip),
|
||||
"dataspell" => Ok(Editor::DataSpell),
|
||||
"android-studio" => Ok(Editor::AndroidStudio),
|
||||
"rustrover" => Ok(Editor::RustRover),
|
||||
"rustrover-preview" => Ok(Editor::RustRoverPreview),
|
||||
"atom" => Ok(Editor::Atom),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
"sublime" | "subl" => Ok(Editor::Sublime),
|
||||
#[cfg(target_os = "macos")]
|
||||
"sublime" | "subl" => Ok(Editor::Sublime4), // Default to latest on macOS
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an editor command string using the provided editor (or $EDITOR as fallback)
|
||||
/// and handle line/column positioning for common command-line editors.
|
||||
/// This is primarily used for generating shell commands when opening files with $EDITOR.
|
||||
pub fn generate_editor_command(
|
||||
path: &std::path::Path,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
editor: Option<&str>,
|
||||
) -> String {
|
||||
let file_path_str = path.to_string_lossy();
|
||||
let quoted_path = shell_words::quote(&file_path_str);
|
||||
|
||||
let editor_cmd = editor.unwrap_or("\"$EDITOR\"").to_owned();
|
||||
|
||||
// Add line/column support for common editors if provided
|
||||
let Some(line_and_col) = line_col else {
|
||||
return format!("{editor_cmd} {quoted_path}");
|
||||
};
|
||||
let Some(editor_name) = editor else {
|
||||
return format!("{editor_cmd} {quoted_path}");
|
||||
};
|
||||
|
||||
let editor_base = std::path::Path::new(editor_name)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(editor_name)
|
||||
.to_lowercase();
|
||||
|
||||
match editor_base.as_str() {
|
||||
// Vim and Neovim: +line or +line:column
|
||||
"vim" | "nvim" | "neovim" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{}:{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Emacs: +line:column
|
||||
"emacs" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{}:{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Nano: +line,column
|
||||
"nano" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{},{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Pico: +line,column (same as nano)
|
||||
"pico" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{},{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Micro: +line:column
|
||||
"micro" => {
|
||||
let line_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("+{}:{}", line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("+{}", line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {line_arg} {quoted_path}")
|
||||
}
|
||||
// Helix: file:line:column
|
||||
"hx" | "helix" => {
|
||||
let file_with_pos = if let Some(col) = line_and_col.column_num {
|
||||
format!("{}:{}:{}", quoted_path, line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("{}:{}", quoted_path, line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} {}", shell_words::quote(&file_with_pos))
|
||||
}
|
||||
// VS Code: --goto file:line:column
|
||||
"code" => {
|
||||
let goto_arg = if let Some(col) = line_and_col.column_num {
|
||||
format!("{}:{}:{}", quoted_path, line_and_col.line_num, col)
|
||||
} else {
|
||||
format!("{}:{}", quoted_path, line_and_col.line_num)
|
||||
};
|
||||
format!("{editor_cmd} --goto {}", shell_words::quote(&goto_arg))
|
||||
}
|
||||
// For unknown editors, fall through to basic command without line support
|
||||
_ => format!("{editor_cmd} {quoted_path}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a file in an external editor, respecting the user's editor settings.
|
||||
/// This reads the configured external editor from EditorSettings and uses it if set,
|
||||
/// otherwise falls back to system default.
|
||||
pub fn open_file_path_in_external_editor(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: PathBuf,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
let editor = match *EditorSettings::as_ref(ctx).open_file_editor {
|
||||
EditorChoice::ExternalEditor(editor) => Some(editor),
|
||||
_ => None,
|
||||
};
|
||||
open_file_path_with_editor(line_column_number, full_path, editor, ctx);
|
||||
}
|
||||
|
||||
pub fn open_file_path_with_editor(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: PathBuf,
|
||||
editor: Option<Editor>,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
mac::open_file_path_with_line_and_col(line_column_number, editor, &full_path, ctx);
|
||||
} else if #[cfg(target_os = "linux")] {
|
||||
linux::open_file_path_with_line_and_col(line_column_number, editor, &full_path, ctx);
|
||||
} else if #[cfg(windows)]{
|
||||
windows::open_file_path_with_line_and_col(line_column_number, editor, &full_path, ctx);
|
||||
} else {
|
||||
ctx.open_file_path(&full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,313 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
|
||||
use super::generate_editor_command;
|
||||
|
||||
#[test]
|
||||
fn test_editor_missing_no_line_col() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let result = generate_editor_command(&path, None, None);
|
||||
assert_eq!(result, "\"$EDITOR\" /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_missing_with_line_col() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: Some(10),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, None);
|
||||
assert_eq!(result, "\"$EDITOR\" /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_present_no_line_col() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let result = generate_editor_command(&path, None, Some("vim"));
|
||||
assert_eq!(result, "vim /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_present_line_missing() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let result = generate_editor_command(&path, None, Some("emacs"));
|
||||
assert_eq!(result, "emacs /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("vim"));
|
||||
assert_eq!(result, "vim +42 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 42,
|
||||
column_num: Some(10),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("vim"));
|
||||
assert_eq!(result, "vim +42:10 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neovim_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 100,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("nvim"));
|
||||
assert_eq!(result, "nvim +100 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_neovim_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 100,
|
||||
column_num: Some(25),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("nvim"));
|
||||
assert_eq!(result, "nvim +100:25 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emacs_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 15,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("emacs"));
|
||||
assert_eq!(result, "emacs +15 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emacs_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 15,
|
||||
column_num: Some(5),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("emacs"));
|
||||
assert_eq!(result, "emacs +15:5 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nano_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 20,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("nano"));
|
||||
assert_eq!(result, "nano +20 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nano_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 20,
|
||||
column_num: Some(8),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("nano"));
|
||||
assert_eq!(result, "nano +20,8 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pico_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 35,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("pico"));
|
||||
assert_eq!(result, "pico +35 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pico_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 35,
|
||||
column_num: Some(12),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("pico"));
|
||||
assert_eq!(result, "pico +35,12 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_micro_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 50,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("micro"));
|
||||
assert_eq!(result, "micro +50 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_micro_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 50,
|
||||
column_num: Some(15),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("micro"));
|
||||
assert_eq!(result, "micro +50:15 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helix_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 75,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("hx"));
|
||||
assert_eq!(result, "hx /path/to/file.txt:75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_helix_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 75,
|
||||
column_num: Some(20),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("helix"));
|
||||
assert_eq!(result, "helix /path/to/file.txt:75:20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vscode_with_line_only() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 90,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("code"));
|
||||
assert_eq!(result, "code --goto /path/to/file.txt:90");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vscode_with_line_and_column() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 90,
|
||||
column_num: Some(30),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("code"));
|
||||
assert_eq!(result, "code --goto /path/to/file.txt:90:30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_editor_with_line_col() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 123,
|
||||
column_num: Some(45),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("unknown-editor"));
|
||||
assert_eq!(result, "unknown-editor /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_with_spaces() {
|
||||
let path = PathBuf::from("/path with spaces/my file.txt");
|
||||
let result = generate_editor_command(&path, None, Some("vim"));
|
||||
assert_eq!(result, "vim '/path with spaces/my file.txt'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_with_special_characters() {
|
||||
let path = PathBuf::from("/path/with$pecial&chars.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 1,
|
||||
column_num: Some(1),
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("emacs"));
|
||||
assert_eq!(result, "emacs +1:1 '/path/with$pecial&chars.txt'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_with_path() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 10,
|
||||
column_num: None,
|
||||
});
|
||||
let result = generate_editor_command(&path, line_col, Some("/usr/bin/vim"));
|
||||
assert_eq!(result, "/usr/bin/vim +10 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive_editor_matching() {
|
||||
let path = PathBuf::from("/path/to/file.txt");
|
||||
let line_col = Some(LineAndColumnArg {
|
||||
line_num: 33,
|
||||
column_num: Some(7),
|
||||
});
|
||||
|
||||
// Test uppercase
|
||||
let result = generate_editor_command(&path, line_col, Some("VIM"));
|
||||
assert_eq!(result, "VIM +33:7 /path/to/file.txt");
|
||||
|
||||
// Test mixed case
|
||||
let result = generate_editor_command(&path, line_col, Some("Emacs"));
|
||||
assert_eq!(result, "Emacs +33:7 /path/to/file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_try_from_supported_editors() {
|
||||
use super::Editor;
|
||||
|
||||
// Test VSCode variants
|
||||
assert_eq!(Editor::try_from("code"), Ok(Editor::VSCode));
|
||||
assert_eq!(
|
||||
Editor::try_from("code-insiders"),
|
||||
Ok(Editor::VSCodeInsiders)
|
||||
);
|
||||
|
||||
// Test Zed variants
|
||||
assert_eq!(Editor::try_from("zed"), Ok(Editor::Zed));
|
||||
assert_eq!(Editor::try_from("zed-preview"), Ok(Editor::ZedPreview));
|
||||
|
||||
// Test other popular editors
|
||||
assert_eq!(Editor::try_from("cursor"), Ok(Editor::Cursor));
|
||||
assert_eq!(Editor::try_from("windsurf"), Ok(Editor::Windsurf));
|
||||
assert_eq!(Editor::try_from("clion"), Ok(Editor::CLion));
|
||||
|
||||
// Test with paths
|
||||
assert_eq!(Editor::try_from("/usr/local/bin/code"), Ok(Editor::VSCode));
|
||||
assert_eq!(
|
||||
Editor::try_from("/Applications/Zed.app/Contents/MacOS/zed"),
|
||||
Ok(Editor::Zed)
|
||||
);
|
||||
|
||||
// Test case insensitivity
|
||||
assert_eq!(Editor::try_from("CODE"), Ok(Editor::VSCode));
|
||||
assert_eq!(Editor::try_from("Zed"), Ok(Editor::Zed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editor_try_from_unsupported_editors() {
|
||||
use super::Editor;
|
||||
|
||||
// Test unsupported terminal editors
|
||||
assert!(Editor::try_from("vim").is_err());
|
||||
assert!(Editor::try_from("emacs").is_err());
|
||||
assert!(Editor::try_from("nano").is_err());
|
||||
assert!(Editor::try_from("unknown-editor").is_err());
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
pub use crate::util::openable_file_type::EditorLayout;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Serialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Which editor to use when opening files.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum EditorChoice {
|
||||
SystemDefault,
|
||||
Warp,
|
||||
EnvEditor,
|
||||
#[schemars(description = "A specific external code editor.")]
|
||||
ExternalEditor(super::Editor),
|
||||
}
|
||||
|
||||
// Custom Deserialize implementation to handle backward compatibility
|
||||
// with the old `Option<Editor>` format
|
||||
impl<'de> Deserialize<'de> for EditorChoice {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum EditorChoiceCompat {
|
||||
// Try new format first
|
||||
New(EditorChoiceInner),
|
||||
// Fall back to old Option<Editor> format
|
||||
Old(Option<super::Editor>),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum EditorChoiceInner {
|
||||
SystemDefault,
|
||||
Warp,
|
||||
EnvEditor,
|
||||
ExternalEditor(super::Editor),
|
||||
}
|
||||
|
||||
match EditorChoiceCompat::deserialize(deserializer)? {
|
||||
EditorChoiceCompat::New(inner) => match inner {
|
||||
EditorChoiceInner::SystemDefault => Ok(EditorChoice::SystemDefault),
|
||||
EditorChoiceInner::Warp => Ok(EditorChoice::Warp),
|
||||
EditorChoiceInner::EnvEditor => Ok(EditorChoice::EnvEditor),
|
||||
EditorChoiceInner::ExternalEditor(editor) => {
|
||||
Ok(EditorChoice::ExternalEditor(editor))
|
||||
}
|
||||
},
|
||||
EditorChoiceCompat::Old(old_value) => match old_value {
|
||||
None => Ok(EditorChoice::SystemDefault),
|
||||
Some(editor) => Ok(EditorChoice::ExternalEditor(editor)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define_settings_group!(EditorSettings, settings: [
|
||||
open_file_editor: OpenFileEditor {
|
||||
type: EditorChoice,
|
||||
default: EditorChoice::SystemDefault,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "code.editor.open_file_editor",
|
||||
max_table_depth: 0,
|
||||
description: "The editor used to open files.",
|
||||
},
|
||||
open_code_panels_file_editor: OpenCodePanelsFileEditor {
|
||||
type: EditorChoice,
|
||||
default: EditorChoice::Warp,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "code.editor.open_code_panels_file_editor",
|
||||
max_table_depth: 0,
|
||||
description: "The editor used to open files from code panels.",
|
||||
},
|
||||
open_file_layout: OpenFileLayout {
|
||||
type: EditorLayout,
|
||||
default: EditorLayout::SplitPane,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.open_file_layout",
|
||||
description: "The layout used when opening files in the editor.",
|
||||
},
|
||||
prefer_markdown_viewer: PreferMarkdownViewer {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.prefer_markdown_viewer",
|
||||
description: "Whether to use the Markdown viewer when opening Markdown files.",
|
||||
},
|
||||
prefer_tabbed_editor_view: PreferTabbedEditorView {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.prefer_tabbed_editor_view",
|
||||
description: "Whether to prefer opening files in a tabbed editor view.",
|
||||
},
|
||||
open_conversation_layout_preference: OpenConversationLayoutPreference {
|
||||
type: OpenConversationPreference,
|
||||
default: OpenConversationPreference::NewTab,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "agents.warp_agent.other.open_conversation_layout_preference",
|
||||
description: "Whether to open agent conversations in a new tab or a split pane.",
|
||||
},
|
||||
]);
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "How to open agent conversations.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum OpenConversationPreference {
|
||||
NewTab,
|
||||
SplitPane,
|
||||
}
|
||||
|
||||
impl OpenConversationPreference {
|
||||
pub fn is_new_tab(&self) -> bool {
|
||||
matches!(self, Self::NewTab)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Module containing logic to determine to open a file in a text editor, if it is installed.
|
||||
//! TODO(PLAT-749): Add support for more editors.
|
||||
|
||||
use command::r#async::Command;
|
||||
use enum_iterator::{all, cardinality};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::AppContext;
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
use winreg::RegKey;
|
||||
use winreg::HKEY;
|
||||
|
||||
use super::Editor;
|
||||
|
||||
static INSTALLED_EDITOR_METADATA: OnceLock<HashMap<Editor, EditorMetadata>> = OnceLock::new();
|
||||
|
||||
struct EditorMetadata {
|
||||
#[allow(unused)]
|
||||
executable_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Enum denoting the method to determine the installation location for a supported editor.
|
||||
enum ExecutableLocationMethod {
|
||||
/// Use the "DisplayIcon" Windows registry key to determine where the app was installed.
|
||||
DisplayIcon,
|
||||
/// Use the "InstallLocation" Windows registry key to determine where the app was installed.
|
||||
InstallLocation {
|
||||
/// The path to the _executable_ from the top level directory where the executable is
|
||||
/// installed.
|
||||
path_to_executable: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExecutableLocationMethod {
|
||||
fn get_executable_path(&self, application_info: RegKey) -> Option<PathBuf> {
|
||||
match self {
|
||||
ExecutableLocationMethod::DisplayIcon => {
|
||||
let display_icon = application_info
|
||||
.get_value::<String, _>("DisplayIcon")
|
||||
.ok()?;
|
||||
|
||||
// Paths for the DisplayIcon key include:
|
||||
// * An icon index after a comma (e.g., "C:\Path\app.exe,0")
|
||||
// * Optionally, surrounding quotes (e.g., ""C:\Path\app.exe",0")
|
||||
// Remove the trailing comma and the surrounding quotes.
|
||||
// This is also the approach GitHub Desktop takes: https://github.com/desktop/desktop/blob/development/app/src/lib/editors/win32.ts#L153.
|
||||
let (path, _) = display_icon.rsplit_once(',')?;
|
||||
Some(path.replace("\"", "").into())
|
||||
}
|
||||
ExecutableLocationMethod::InstallLocation { path_to_executable } => {
|
||||
let install_location = application_info
|
||||
.get_value::<String, _>("InstallLocation")
|
||||
.ok()?;
|
||||
Some(PathBuf::from(install_location).join(path_to_executable))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes a list of installed editors, and any corresponding metadata.
|
||||
fn compute_installed_editors() -> HashMap<Editor, EditorMetadata> {
|
||||
const SCOPES: [(HKEY, &str); 3] = [
|
||||
(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
"SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
||||
),
|
||||
(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
||||
),
|
||||
(
|
||||
HKEY_CURRENT_USER,
|
||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
|
||||
),
|
||||
];
|
||||
|
||||
let mut installed_editors = HashMap::with_capacity(cardinality::<Editor>());
|
||||
|
||||
// Generate a mapping from each app ID to the editor the app ID corresponds to.
|
||||
let app_id_to_editors: HashMap<&'static str, Editor> = all::<Editor>()
|
||||
.flat_map(|editor| editor.app_ids().iter().map(move |app_id| (*app_id, editor)))
|
||||
.collect();
|
||||
|
||||
// Determine all the installed applications by reading out install metadata from the windows
|
||||
// registry.
|
||||
for (scope, key) in SCOPES {
|
||||
let uninstall_key = match RegKey::predef(scope).open_subkey(key) {
|
||||
Ok(k) => k,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for application_id in uninstall_key.enum_keys().flatten() {
|
||||
let Some(editor) = app_id_to_editors.get(application_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(application_info) = uninstall_key.open_subkey(&application_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(executable_path) = editor
|
||||
.executable_location_method()
|
||||
.and_then(|key| key.get_executable_path(application_info))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let editor_metadata = EditorMetadata { executable_path };
|
||||
installed_editors.insert(*editor, editor_metadata);
|
||||
}
|
||||
}
|
||||
|
||||
installed_editors
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn is_installed(&self, _ctx: &mut AppContext) -> bool {
|
||||
INSTALLED_EDITOR_METADATA
|
||||
.get_or_init(compute_installed_editors)
|
||||
.contains_key(self)
|
||||
}
|
||||
|
||||
/// Returns the set of IDs that identify a given Editor.
|
||||
fn app_ids(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Editor::VSCode => {
|
||||
&[
|
||||
// 64-bit version of VSCode (user) - provided by default in 64-bit Windows
|
||||
"{771FD6B0-FA20-440A-A002-3B3BAC16DC50}_is1",
|
||||
// 32-bit version of VSCode (user)
|
||||
"{D628A17A-9713-46BF-8D57-E671B46A741E}_is1",
|
||||
// ARM64 version of VSCode (user)
|
||||
"{D9E514E7-1A56-452D-9337-2990C0DC4310}_is1",
|
||||
// 64-bit version of VSCode (system) - was default before user scope installation
|
||||
"EA457B21-F73E-494C-ACAB-524FDE069978}_is1",
|
||||
// 32-bit version of VSCode (system)
|
||||
"{F8A2A208-72B3-4D61-95FC-8A65D340689B}_is1",
|
||||
// ARM64 version of VSCode (system)
|
||||
"{A5270FC5-65AD-483E-AC30-2C276B63D0AC}_is1",
|
||||
]
|
||||
}
|
||||
Editor::Cursor => &["62625861-8486-5be9-9e46-1da50df5f8ff"],
|
||||
Editor::Windsurf => &["{5A8B7D94-9B5F-4D1F-93FC-5609F7159349}_is1"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn executable_location_method(&self) -> Option<ExecutableLocationMethod> {
|
||||
match self {
|
||||
Editor::VSCode => Some(ExecutableLocationMethod::InstallLocation {
|
||||
path_to_executable: Path::new("bin").join("code.exe"),
|
||||
}),
|
||||
Editor::Windsurf => Some(ExecutableLocationMethod::InstallLocation {
|
||||
path_to_executable: Path::new("bin").join("windsurf.exe"),
|
||||
}),
|
||||
Editor::Cursor => Some(ExecutableLocationMethod::DisplayIcon),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(
|
||||
&self,
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
full_path: &Path,
|
||||
) -> Option<Command> {
|
||||
let command = match self {
|
||||
Editor::VSCode => {
|
||||
let mut command = Command::new("explorer.exe");
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
command.arg(format!("vscode://file/{}{suffix}", full_path.display()));
|
||||
command
|
||||
}
|
||||
Editor::Cursor => {
|
||||
let mut command = Command::new("explorer.exe");
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
command.arg(format!("cursor://file/{}{suffix}", full_path.display()));
|
||||
command
|
||||
}
|
||||
Editor::Windsurf => {
|
||||
let mut command = Command::new("explorer.exe");
|
||||
let suffix = line_column_number
|
||||
.as_ref()
|
||||
.map(LineAndColumnArg::to_string_suffix)
|
||||
.unwrap_or_default();
|
||||
command.arg(format!("windsurf://file/{}{suffix}", full_path.display()));
|
||||
command
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(command)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the given file in the specified editor.
|
||||
///
|
||||
/// If `line_column_number` is `Some`, the file will be opened with the cursor
|
||||
/// at the given location (if supported by the editor).
|
||||
///
|
||||
/// If with_editor is `None`, we attempt to compute the default editor for the
|
||||
/// given file type, and open the file there.
|
||||
pub fn open_file_path_with_line_and_col(
|
||||
line_column_number: Option<LineAndColumnArg>,
|
||||
mut with_editor: Option<Editor>,
|
||||
full_path: &Path,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
if full_path.is_file() {
|
||||
with_editor = with_editor.filter(|editor| editor.is_installed(ctx));
|
||||
if let Some(editor) = with_editor {
|
||||
if let Some(mut command) = editor.command(line_column_number, full_path) {
|
||||
if let Err(err) = command.spawn() {
|
||||
log::error!("Error launching {editor:?}: {err:#}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.open_file_path(full_path);
|
||||
}
|
||||
+1054
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
use std::path::Path;
|
||||
|
||||
use command::r#async::Command;
|
||||
use command::Stdio;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::{detect_current_branch, detect_current_branch_display};
|
||||
|
||||
/// Helper: run a git command inside the given repo directory.
|
||||
async fn git(repo: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.expect("failed to run git");
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_owned()
|
||||
}
|
||||
|
||||
/// Creates a temp git repo with one commit and returns `(dir_handle, repo_path)`.
|
||||
async fn init_repo() -> (TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let path = dir.path().to_path_buf();
|
||||
|
||||
git(&path, &["init", "-b", "main"]).await;
|
||||
git(&path, &["config", "user.email", "test@test.com"]).await;
|
||||
git(&path, &["config", "user.name", "Test"]).await;
|
||||
git(&path, &["commit", "--allow-empty", "-m", "initial"]).await;
|
||||
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_normal_branch_returns_branch_name() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
git(&repo, &["checkout", "-b", "feature-xyz"]).await;
|
||||
|
||||
assert_eq!(detect_current_branch(&repo).await.unwrap(), "feature-xyz");
|
||||
assert_eq!(
|
||||
detect_current_branch_display(&repo).await.unwrap(),
|
||||
"feature-xyz"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detached_head_raw_returns_head() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
git(&repo, &["checkout", "--detach", "HEAD"]).await;
|
||||
|
||||
assert_eq!(detect_current_branch(&repo).await.unwrap(), "HEAD");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detached_head_display_returns_short_sha() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
let full_sha = git(&repo, &["rev-parse", "HEAD"]).await;
|
||||
git(&repo, &["checkout", "--detach", "HEAD"]).await;
|
||||
|
||||
let result = detect_current_branch_display(&repo).await.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
result, "HEAD",
|
||||
"display variant should not return literal HEAD"
|
||||
);
|
||||
assert!(
|
||||
full_sha.starts_with(&result),
|
||||
"expected {full_sha} to start with {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detached_tag_display_returns_short_sha() {
|
||||
let (_dir, repo) = init_repo().await;
|
||||
git(&repo, &["tag", "v1.0"]).await;
|
||||
git(&repo, &["checkout", "v1.0"]).await;
|
||||
|
||||
let full_sha = git(&repo, &["rev-parse", "HEAD"]).await;
|
||||
let result = detect_current_branch_display(&repo).await.unwrap();
|
||||
|
||||
assert_ne!(result, "HEAD");
|
||||
assert!(
|
||||
full_sha.starts_with(&result),
|
||||
"expected {full_sha} to start with {result}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Shared image processing utilities for agent mode.
|
||||
//!
|
||||
//! This module provides common functionality for processing images before they are
|
||||
//! sent to the AI agent, whether attached by the user or read via the read_files tool.
|
||||
|
||||
use image::{GenericImageView, ImageError};
|
||||
|
||||
/// Max image size is 3.75 MB.
|
||||
/// The max size of an image we will send is 5MB. However, due to the 33% inflation of Base64, this means
|
||||
/// the largest size a user can attach is actually ~3.75MB.
|
||||
pub const MAX_IMAGE_SIZE_BYTES: usize = 3750 * 1000;
|
||||
|
||||
/// 1.15 Megapixels
|
||||
pub const MAX_IMAGE_PIXELS: f64 = 1150. * 1000.;
|
||||
|
||||
/// Maximum dimension (width or height) for images.
|
||||
pub const MAX_IMAGE_DIMENSION: f64 = 2000.;
|
||||
|
||||
/// Maximum number of images that can be attached per query/task.
|
||||
pub const MAX_IMAGE_COUNT_FOR_QUERY: usize = 20;
|
||||
|
||||
/// Minimum bytes needed for image format detection using magic number signatures.
|
||||
pub const MIN_IMAGE_HEADER_SIZE: usize = 8;
|
||||
|
||||
/// Supported image MIME types for agent mode.
|
||||
pub const SUPPORTED_IMAGE_MIME_TYPES: &[&str] = &[
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
/// Checks if the given MIME type is a supported image type.
|
||||
pub fn is_supported_image_mime_type(mime_type: &str) -> bool {
|
||||
SUPPORTED_IMAGE_MIME_TYPES.contains(&mime_type)
|
||||
}
|
||||
|
||||
/// Resizes an image if it exceeds the maximum pixel count, and ensures
|
||||
/// resized outputs also respect the maximum dimension (width or height).
|
||||
///
|
||||
/// Returns the original image bytes if the image is already within the
|
||||
/// pixel limit; otherwise returns the resized image bytes in the original
|
||||
/// format.
|
||||
pub fn resize_image(image: &[u8]) -> Result<Vec<u8>, ImageError> {
|
||||
let img = image::load_from_memory(image)?;
|
||||
|
||||
let (current_width, current_height) = img.dimensions();
|
||||
let current_pixels = (current_width * current_height) as f64;
|
||||
|
||||
if current_pixels <= MAX_IMAGE_PIXELS {
|
||||
return Ok(image.to_vec());
|
||||
}
|
||||
|
||||
let original_format = image::guess_format(image)?;
|
||||
|
||||
let scale = (MAX_IMAGE_PIXELS / current_pixels).sqrt();
|
||||
|
||||
let mut new_width = current_width as f64 * scale;
|
||||
let mut new_height = current_height as f64 * scale;
|
||||
|
||||
let scale_by_width = MAX_IMAGE_DIMENSION / new_width;
|
||||
let scale_by_height = MAX_IMAGE_DIMENSION / new_height;
|
||||
let scale = scale_by_width.min(scale_by_height).min(1.0);
|
||||
|
||||
new_width *= scale;
|
||||
new_height *= scale;
|
||||
|
||||
let resized_img = img.thumbnail(new_width.round() as u32, new_height.round() as u32);
|
||||
|
||||
let mut output_bytes: Vec<u8> = Vec::new();
|
||||
let mut writer = std::io::Cursor::new(&mut output_bytes);
|
||||
|
||||
resized_img.write_to(&mut writer, original_format)?;
|
||||
|
||||
Ok(output_bytes)
|
||||
}
|
||||
|
||||
/// Result of processing an image for agent mode.
|
||||
#[derive(Debug)]
|
||||
pub enum ProcessImageResult {
|
||||
/// Image was successfully processed and is within size limits.
|
||||
Success {
|
||||
/// The processed image bytes (resized if needed).
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Image is too large even after resizing.
|
||||
TooLarge,
|
||||
/// Error processing the image.
|
||||
Error(ImageError),
|
||||
}
|
||||
|
||||
/// Processes an image for agent mode: resizes if needed and checks size limits.
|
||||
///
|
||||
/// This applies the same processing that user-attached images go through.
|
||||
pub fn process_image_for_agent(image_data: &[u8]) -> ProcessImageResult {
|
||||
match resize_image(image_data) {
|
||||
Ok(resized_bytes) => {
|
||||
if resized_bytes.len() > MAX_IMAGE_SIZE_BYTES {
|
||||
ProcessImageResult::TooLarge
|
||||
} else {
|
||||
ProcessImageResult::Success {
|
||||
data: resized_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => ProcessImageResult::Error(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "image_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_supported_image_mime_type() {
|
||||
assert!(is_supported_image_mime_type("image/png"));
|
||||
assert!(is_supported_image_mime_type("image/jpeg"));
|
||||
assert!(is_supported_image_mime_type("image/jpg"));
|
||||
assert!(is_supported_image_mime_type("image/gif"));
|
||||
assert!(is_supported_image_mime_type("image/webp"));
|
||||
assert!(!is_supported_image_mime_type("image/bmp"));
|
||||
assert!(!is_supported_image_mime_type("application/pdf"));
|
||||
assert!(!is_supported_image_mime_type("text/plain"));
|
||||
}
|
||||
|
||||
/// Creates a small test PNG image and returns its bytes.
|
||||
fn create_small_test_png() -> Vec<u8> {
|
||||
use image::{ImageBuffer, Rgba};
|
||||
// Create a small 10x10 red image
|
||||
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_fn(10, 10, |_x, _y| Rgba([255u8, 0u8, 0u8, 255u8]));
|
||||
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut bytes);
|
||||
img.write_to(&mut cursor, image::ImageFormat::Png).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_image_for_agent_small_image() {
|
||||
let small_png = create_small_test_png();
|
||||
let result = process_image_for_agent(&small_png);
|
||||
|
||||
match result {
|
||||
ProcessImageResult::Success { data } => {
|
||||
// The processed image should be non-empty
|
||||
assert!(!data.is_empty());
|
||||
// For a small image, it should not be resized, so data should be similar
|
||||
assert!(data.len() <= MAX_IMAGE_SIZE_BYTES);
|
||||
}
|
||||
other => panic!("Expected Success, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_image_small_image_unchanged() {
|
||||
let small_png = create_small_test_png();
|
||||
let original_len = small_png.len();
|
||||
|
||||
let result = resize_image(&small_png).unwrap();
|
||||
// Small images should be returned as-is
|
||||
assert_eq!(result.len(), original_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_image_for_agent_invalid_data() {
|
||||
let invalid_data = vec![0u8; 100];
|
||||
let result = process_image_for_agent(&invalid_data);
|
||||
|
||||
match result {
|
||||
ProcessImageResult::Error(_) => {
|
||||
// Expected - invalid data should produce an error
|
||||
}
|
||||
other => panic!("Expected Error, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a large test PNG image that exceeds MAX_IMAGE_PIXELS.
|
||||
fn create_large_test_png() -> Vec<u8> {
|
||||
use image::{ImageBuffer, Rgba};
|
||||
// Create a 2000x1000 image (2M pixels, exceeds MAX_IMAGE_PIXELS of 1.15M)
|
||||
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_fn(2000, 1000, |_x, _y| Rgba([255u8, 0u8, 0u8, 255u8]));
|
||||
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut bytes);
|
||||
img.write_to(&mut cursor, image::ImageFormat::Png).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_image_large_image_gets_resized() {
|
||||
let large_png = create_large_test_png();
|
||||
|
||||
// Verify the image exceeds the pixel limit
|
||||
let img = image::load_from_memory(&large_png).unwrap();
|
||||
let (width, height) = img.dimensions();
|
||||
let original_pixels = (width * height) as f64;
|
||||
assert!(original_pixels > MAX_IMAGE_PIXELS);
|
||||
|
||||
let result = resize_image(&large_png).unwrap();
|
||||
|
||||
// The resized image should be smaller
|
||||
let resized_img = image::load_from_memory(&result).unwrap();
|
||||
let (new_width, new_height) = resized_img.dimensions();
|
||||
let new_pixels = (new_width * new_height) as f64;
|
||||
|
||||
assert!(new_pixels <= MAX_IMAGE_PIXELS);
|
||||
assert!(new_width < width || new_height < height);
|
||||
}
|
||||
|
||||
/// Creates a very tall/narrow image to test dimension clamping.
|
||||
fn create_tall_test_png() -> Vec<u8> {
|
||||
use image::{ImageBuffer, Rgba};
|
||||
// Create a 100x20000 image (2M pixels, but very tall)
|
||||
// After pixel-based scaling, height would still exceed MAX_IMAGE_DIMENSION
|
||||
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||
ImageBuffer::from_fn(100, 20000, |_x, _y| Rgba([0u8, 255u8, 0u8, 255u8]));
|
||||
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut bytes);
|
||||
img.write_to(&mut cursor, image::ImageFormat::Png).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_image_respects_max_dimension() {
|
||||
let tall_png = create_tall_test_png();
|
||||
|
||||
let result = resize_image(&tall_png).unwrap();
|
||||
|
||||
let resized_img = image::load_from_memory(&result).unwrap();
|
||||
let (new_width, new_height) = resized_img.dimensions();
|
||||
|
||||
// Both dimensions should be within MAX_IMAGE_DIMENSION
|
||||
assert!(
|
||||
(new_width as f64) <= MAX_IMAGE_DIMENSION,
|
||||
"width {} exceeds max {}",
|
||||
new_width,
|
||||
MAX_IMAGE_DIMENSION
|
||||
);
|
||||
assert!(
|
||||
(new_height as f64) <= MAX_IMAGE_DIMENSION,
|
||||
"height {} exceeds max {}",
|
||||
new_height,
|
||||
MAX_IMAGE_DIMENSION
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use urlocator::{UrlLocation, UrlLocator};
|
||||
use warpui::elements::PartialClickableElement;
|
||||
|
||||
use warpui::platform::Cursor;
|
||||
|
||||
use crate::ai::agent::{AIAgentActionType, AIAgentOutput, AIAgentTextSection, ReadFilesRequest};
|
||||
use crate::ai::blocklist::block::view_impl::output::LinkActionConstructors;
|
||||
use crate::ai::blocklist::block::TextLocation;
|
||||
use crate::terminal::links::should_directly_open_link;
|
||||
use crate::terminal::model::grid::grid_handler::FILE_LINK_SEPARATORS;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::text::char_slice;
|
||||
use warpui::Action;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use warp_util::path::CleanPathResult;
|
||||
}
|
||||
}
|
||||
|
||||
pub const RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID: &str =
|
||||
"ai_block:rich_content_link_first_char_position";
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) struct LinkLocation {
|
||||
pub(crate) link_range: Range<usize>,
|
||||
pub(crate) location: TextLocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct DetectedLinksState {
|
||||
pub(crate) detected_links_by_location: HashMap<TextLocation, DetectedLinksInTextLocation>,
|
||||
// The link that the mouse is currently hovered over.
|
||||
pub(crate) currently_hovered_link_location: Option<LinkLocation>,
|
||||
// The link that a tooltip is currently open for.
|
||||
// This is separate from currently_hovered_link because after clicking
|
||||
// on a link to open the tooltip, this link should remain highlighted and the tooltip in place
|
||||
// even if we hover over other links.
|
||||
pub(crate) link_location_open_tooltip: Option<LinkLocation>,
|
||||
}
|
||||
|
||||
impl DetectedLinksState {
|
||||
/// Given a text location and char range, returns the detected link there if any.
|
||||
pub fn link_at(
|
||||
&self,
|
||||
location: &TextLocation,
|
||||
range: &Range<usize>,
|
||||
) -> Option<&DetectedLinkType> {
|
||||
Some(
|
||||
&self
|
||||
.detected_links_by_location
|
||||
.get(location)?
|
||||
.detected_links
|
||||
.get(range)?
|
||||
.link,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_hovered_link(
|
||||
&mut self,
|
||||
is_hovering: bool,
|
||||
is_selecting: bool,
|
||||
link_range: &Range<usize>,
|
||||
location: &TextLocation,
|
||||
) {
|
||||
if is_hovering && !is_selecting {
|
||||
self.currently_hovered_link_location = Some(LinkLocation {
|
||||
link_range: link_range.clone(),
|
||||
location: *location,
|
||||
});
|
||||
} else if self.currently_hovered_link_location.as_ref().is_some_and(
|
||||
|currently_hovered_link| {
|
||||
currently_hovered_link.link_range == *link_range
|
||||
&& currently_hovered_link.location == *location
|
||||
},
|
||||
) {
|
||||
self.currently_hovered_link_location = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces all detected links with the given background detection results.
|
||||
pub(crate) fn replace_all_links(
|
||||
&mut self,
|
||||
all_links: HashMap<TextLocation, HashMap<Range<usize>, DetectedLinkType>>,
|
||||
) {
|
||||
self.detected_links_by_location.clear();
|
||||
self.currently_hovered_link_location = None;
|
||||
self.link_location_open_tooltip = None;
|
||||
for (location, links) in all_links {
|
||||
let entry = self.detected_links_by_location.entry(location).or_default();
|
||||
for (range, link) in links {
|
||||
entry.detected_links.insert(
|
||||
range,
|
||||
HoverableDetectedLink {
|
||||
link,
|
||||
mouse_state: Default::default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum DetectedLinkType {
|
||||
Url(String),
|
||||
#[cfg(feature = "local_fs")]
|
||||
FilePath {
|
||||
absolute_path: PathBuf,
|
||||
line_and_column_num: Option<warp_util::path::LineAndColumnArg>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct HoverableDetectedLink {
|
||||
pub(crate) link: DetectedLinkType,
|
||||
pub(crate) mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct DetectedLinksInTextLocation {
|
||||
pub(crate) detected_links: HashMap<Range<usize>, HoverableDetectedLink>,
|
||||
}
|
||||
|
||||
pub(crate) fn add_link_detection_mouse_interactions<T: PartialClickableElement, A: Action>(
|
||||
mut element: T,
|
||||
detected_links_state: &DetectedLinksState,
|
||||
link_action_constructors: LinkActionConstructors<A>,
|
||||
location: TextLocation,
|
||||
) -> T {
|
||||
if let Some(detected_links) = detected_links_state
|
||||
.detected_links_by_location
|
||||
.get(&location)
|
||||
{
|
||||
for (detected_link_range, hoverable_link) in &detected_links.detected_links {
|
||||
let detected_link_range_clone = detected_link_range.clone();
|
||||
element = element.with_clickable_char_range(
|
||||
detected_link_range_clone.clone(),
|
||||
move |modifiers, ctx, _app| {
|
||||
if should_directly_open_link(modifiers) {
|
||||
let action = (link_action_constructors.construct_open_link_action)(
|
||||
detected_link_range_clone.clone(),
|
||||
location,
|
||||
);
|
||||
ctx.dispatch_typed_action(action);
|
||||
} else {
|
||||
let action = (link_action_constructors.construct_open_link_tooltip_action)(
|
||||
detected_link_range_clone.clone(),
|
||||
location,
|
||||
);
|
||||
ctx.dispatch_typed_action(action);
|
||||
}
|
||||
},
|
||||
);
|
||||
let detected_link_range_clone = detected_link_range.clone();
|
||||
element = element.with_hoverable_char_range(
|
||||
detected_link_range_clone.clone(),
|
||||
hoverable_link.mouse_state.clone(),
|
||||
Some(Cursor::PointingHand),
|
||||
move |is_hovering, ctx, _app| {
|
||||
let action = (link_action_constructors.construct_changed_hover_on_link_action)(
|
||||
detected_link_range_clone.clone(),
|
||||
location,
|
||||
is_hovering,
|
||||
);
|
||||
ctx.dispatch_typed_action(action);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
element
|
||||
}
|
||||
|
||||
/// Returns the char ranges of detected URLs in the given text.
|
||||
fn detect_urls(text: &str) -> Vec<Range<usize>> {
|
||||
let mut locator = UrlLocator::new();
|
||||
let mut url_ranges = vec![];
|
||||
let (mut start, mut end) = (None, None);
|
||||
for (i, c) in text.chars().enumerate() {
|
||||
// Reference to https://docs.rs/urlocator/latest/urlocator/#example-url-boundaries
|
||||
// We know we have fully parsed an url when the locator advances from the `UrlLocation::Url`
|
||||
// to the `UrlLocation::Reset` stage.
|
||||
match locator.advance(c) {
|
||||
UrlLocation::Url(length, end_offset) => {
|
||||
end = Some(1 + i - end_offset as usize);
|
||||
start = Some(end.unwrap() - length as usize);
|
||||
}
|
||||
UrlLocation::Reset => {
|
||||
if let Some((start, end)) = start.zip(end) {
|
||||
url_ranges.push(start..end)
|
||||
}
|
||||
start = None;
|
||||
end = None;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
// If the last character completes a valid URL, add it.
|
||||
if let Some((start, end)) = start.zip(end) {
|
||||
url_ranges.push(start..end)
|
||||
}
|
||||
url_ranges
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
fn addr_of(s: &str) -> usize {
|
||||
s.as_ptr() as usize
|
||||
}
|
||||
|
||||
/// Given a word with no whitespace in it, returns all the possible file paths within the word
|
||||
/// from longest to shortest. File paths within a word can be split by a list of FILE_LINK_SEPARATORS,
|
||||
/// and those separators may be part of file paths themselves.
|
||||
/// Possible file paths begin after a separator and end before a separator.
|
||||
/// For example, given /path/to/file:16:hello, it will return
|
||||
/// ["/path/to/file:16:hello", "/path/to/file:16", "/path/to/file", "16:hello", "hello"]
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
fn possible_file_paths_in_word(word: &str) -> impl Iterator<Item = &str> {
|
||||
// To include any substrings starting at the beginning of the word, we
|
||||
// pretend there's a separator before the first character.
|
||||
let mut separator_byte_indices = vec![-1];
|
||||
// We use char_indices() to get byte indices of each char which are used to index the string,
|
||||
// rather than chars().enumerate() would give char indices.
|
||||
for (i, c) in word.char_indices() {
|
||||
if FILE_LINK_SEPARATORS.contains(&c) {
|
||||
separator_byte_indices.push(i as i32);
|
||||
}
|
||||
}
|
||||
// Consider trailing periods to be separators. This is because
|
||||
// in natural language we might use a file path at the end of a sentence, and want
|
||||
// to detect them without including the trailing period. But trailing
|
||||
// periods can also be part of a valid file path.
|
||||
let word_ends_with_period = word.ends_with('.');
|
||||
if word_ends_with_period {
|
||||
separator_byte_indices.push((word.len() - 1) as i32);
|
||||
}
|
||||
// To include any substrings ending at the end of the word, we pretend there's
|
||||
// a separator after the last character.
|
||||
separator_byte_indices.push(word.len() as i32);
|
||||
let mut possible_path_byte_ranges = vec![];
|
||||
for (i, start_index) in separator_byte_indices.iter().cloned().enumerate() {
|
||||
for end_index in separator_byte_indices.iter().skip(i + 1).cloned() {
|
||||
if start_index + 1 < end_index {
|
||||
possible_path_byte_ranges.push(start_index + 1..end_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by longest to shortest.
|
||||
possible_path_byte_ranges.sort_by(|a, b| (b.end - b.start).cmp(&(a.end - a.start)));
|
||||
possible_path_byte_ranges
|
||||
.into_iter()
|
||||
.map(|range| &word[(range.start as usize)..(range.end as usize)])
|
||||
}
|
||||
|
||||
/// Returns a DetectedLink::FilePath if expanded_path is a valid path that actually exists on the file system.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn compute_valid_file_path(
|
||||
working_directory: &Path,
|
||||
expanded_path: &str,
|
||||
files_and_folders_in_working_directory: &HashSet<PathBuf>,
|
||||
shell_launch_data: Option<&crate::terminal::ShellLaunchData>,
|
||||
) -> Option<DetectedLinkType> {
|
||||
use crate::util::file::{absolute_path_if_valid, ShellPathType};
|
||||
// Scan for line and column number in the current word (left + right).
|
||||
let cleaned_path = CleanPathResult::with_line_and_column_number(expanded_path);
|
||||
|
||||
// First try to use the files_and_folders_in_working_directory cache.
|
||||
let path = Path::new(&cleaned_path.path);
|
||||
if let Some(relative_path) = files_and_folders_in_working_directory.get(path) {
|
||||
let absolute_path = working_directory.join(relative_path);
|
||||
return Some(DetectedLinkType::FilePath {
|
||||
absolute_path,
|
||||
line_and_column_num: cleaned_path.line_and_column_num,
|
||||
});
|
||||
} else if path.components().count() <= 1 {
|
||||
// If the path does not contain a separator and isn't in files_and_folders_in_working_directory,
|
||||
// we know it isn't a valid path. Return immediately to save a a file system call.
|
||||
return None;
|
||||
}
|
||||
|
||||
// This does a file system lookup.
|
||||
let absolute_path = absolute_path_if_valid(
|
||||
&cleaned_path,
|
||||
ShellPathType::PlatformNative(working_directory.to_owned()),
|
||||
shell_launch_data,
|
||||
);
|
||||
|
||||
absolute_path.map(|absolute_path| DetectedLinkType::FilePath {
|
||||
absolute_path,
|
||||
line_and_column_num: cleaned_path.line_and_column_num,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a set of all file and folder names in the given directory (relative, not absolute paths).
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn get_files_and_folders_in_directory(directory: &Path) -> HashSet<PathBuf> {
|
||||
let mut files_and_folders = HashSet::new();
|
||||
let Ok(entries) = std::fs::read_dir(directory) else {
|
||||
return files_and_folders;
|
||||
};
|
||||
for entry in entries {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
files_and_folders.insert(PathBuf::from(entry.file_name()));
|
||||
}
|
||||
files_and_folders
|
||||
}
|
||||
|
||||
/// Returns the detected valid file paths in some text along with their char ranges.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn detect_file_paths(
|
||||
working_directory: &str,
|
||||
text: &str,
|
||||
shell_launch_data: Option<&ShellLaunchData>,
|
||||
) -> HashMap<Range<usize>, DetectedLinkType> {
|
||||
let mut file_paths = HashMap::new();
|
||||
// List files in this working_directory
|
||||
let working_directory = shell_launch_data
|
||||
.and_then(|launch_data| launch_data.maybe_convert_absolute_path(working_directory))
|
||||
.unwrap_or_else(|| {
|
||||
// Naively attempt to make a pathbuf from this.
|
||||
PathBuf::from(working_directory)
|
||||
});
|
||||
let files_and_folders_in_working_directory =
|
||||
get_files_and_folders_in_directory(working_directory.as_path());
|
||||
for word in text.split_whitespace() {
|
||||
let possible_paths = possible_file_paths_in_word(word);
|
||||
// In the word, there can be multiple valid file paths which may or may not overlap.
|
||||
// Take the longest one to turn into a link.
|
||||
for possible_path in possible_paths {
|
||||
// Need to expand the path here as built-in Path lib does not understand tilde.
|
||||
let expanded_path = shellexpand::tilde(possible_path);
|
||||
if let Some(path_type) = compute_valid_file_path(
|
||||
working_directory.as_path(),
|
||||
&expanded_path,
|
||||
&files_and_folders_in_working_directory,
|
||||
shell_launch_data,
|
||||
) {
|
||||
let byte_start = addr_of(possible_path) - addr_of(text);
|
||||
let byte_end = byte_start + possible_path.len();
|
||||
let char_start = text[..byte_start].chars().count();
|
||||
let char_end = char_start + possible_path.chars().count();
|
||||
file_paths.insert(char_start..char_end, path_type.clone());
|
||||
|
||||
// Check for line ranges after this file path and add them as separate clickable links
|
||||
if let Some(line_ranges) = detect_line_ranges_after_file_path(text, byte_end) {
|
||||
// Extract the base file path from the existing path_type
|
||||
if let DetectedLinkType::FilePath { absolute_path, .. } = &path_type {
|
||||
for (line_number, char_range) in line_ranges {
|
||||
// Create a new DetectedLinkType with the same file path but with the line number
|
||||
let line_range_link = DetectedLinkType::FilePath {
|
||||
absolute_path: absolute_path.clone(),
|
||||
line_and_column_num: Some(warp_util::path::LineAndColumnArg {
|
||||
line_num: line_number as usize,
|
||||
column_num: None,
|
||||
}),
|
||||
};
|
||||
file_paths.insert(char_range, line_range_link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
file_paths
|
||||
}
|
||||
|
||||
use string_offset::CharOffset;
|
||||
use warp_editor::content::buffer::Buffer;
|
||||
use warpui::text::word_boundaries::WordBoundariesPolicy;
|
||||
|
||||
/// Returns the range of the word surrounding the given offset.
|
||||
pub(crate) fn get_word_range_at_offset(
|
||||
buffer: &Buffer,
|
||||
offset: CharOffset,
|
||||
word_boundary_policy: Option<WordBoundariesPolicy>,
|
||||
) -> Option<Range<CharOffset>> {
|
||||
use warp_editor::content::buffer::{ToBufferCharOffset, ToBufferPoint};
|
||||
use warpui::text::words::is_default_word_boundary;
|
||||
use warpui::text::TextBuffer;
|
||||
|
||||
let word_boundary_policy = word_boundary_policy.unwrap_or(WordBoundariesPolicy::Default);
|
||||
let mut word_found_at: Option<CharOffset> = None;
|
||||
let mut cursor_offset = offset;
|
||||
|
||||
if let Ok(chars) = buffer.chars_at(offset) {
|
||||
for c in chars {
|
||||
if c == '\n' {
|
||||
// Do not cross line boundaries when searching for the nearest word
|
||||
break;
|
||||
}
|
||||
if !is_default_word_boundary(c) {
|
||||
word_found_at = Some(cursor_offset);
|
||||
break;
|
||||
}
|
||||
// advance one character
|
||||
cursor_offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let found_offset = word_found_at?;
|
||||
let found_point = found_offset.to_buffer_point(buffer);
|
||||
|
||||
let word_start_point = buffer
|
||||
.word_starts_backward_from_offset_inclusive(found_point)
|
||||
.ok()
|
||||
.map(|iter| iter.with_policy(&word_boundary_policy))
|
||||
.and_then(|mut iter| iter.next())
|
||||
.unwrap_or(found_point);
|
||||
|
||||
let word_end_point = buffer
|
||||
.word_ends_from_offset_exclusive(found_point)
|
||||
.ok()
|
||||
.map(|iter| iter.with_policy(&word_boundary_policy))
|
||||
.and_then(|mut iter| iter.next())
|
||||
.unwrap_or(found_point);
|
||||
|
||||
let word_start = word_start_point.to_buffer_char_offset(buffer);
|
||||
let word_end = word_end_point.to_buffer_char_offset(buffer);
|
||||
|
||||
if word_start < word_end {
|
||||
Some(word_start..word_end)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse line ranges from comma-separated text content and return detected ranges.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn parse_line_range(
|
||||
potential_range: &str,
|
||||
text: &str,
|
||||
) -> Result<(u32, Range<usize>), &'static str> {
|
||||
let potential_range = potential_range.trim();
|
||||
|
||||
// Look for pattern "number-number"
|
||||
let dash_pos = potential_range.find('-').ok_or("No dash found in range")?;
|
||||
|
||||
// Extracting starting line number for potential range
|
||||
let start_str = potential_range[..dash_pos].trim();
|
||||
let start_line = start_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| "Failed to parse start line number")?;
|
||||
let end_str = potential_range[dash_pos + 1..].trim();
|
||||
end_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| "Failed to parse end line number")?;
|
||||
|
||||
let range_start_bytes = addr_of(potential_range) - addr_of(text);
|
||||
let char_start = text[..range_start_bytes].chars().count();
|
||||
let range_end_bytes = range_start_bytes + potential_range.len();
|
||||
let char_end = text[..range_end_bytes].chars().count();
|
||||
|
||||
Ok((start_line, char_start..char_end))
|
||||
}
|
||||
|
||||
/// Helper function to detect line ranges that appear after a valid file path.
|
||||
/// Looks for patterns like "file.rs (1-50, 100-150)" and returns the detected ranges.
|
||||
/// Returns a vector of (line_number, char_range) tuples.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn detect_line_ranges_after_file_path(
|
||||
text: &str,
|
||||
file_path_byte_end: usize,
|
||||
) -> Option<Vec<(u32, Range<usize>)>> {
|
||||
let chars_iter = text[file_path_byte_end..]
|
||||
.char_indices()
|
||||
.map(|(offs, ch)| (offs + file_path_byte_end, ch));
|
||||
|
||||
// Finds an opening paranthesis, allowing some whitespace after file path, or returns None on failure
|
||||
let mut paren_start_idx = None;
|
||||
for (char_idx, ch) in chars_iter {
|
||||
if ch == '(' {
|
||||
paren_start_idx = Some(char_idx);
|
||||
break;
|
||||
} else if !ch.is_whitespace() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let paren_start_idx = paren_start_idx?;
|
||||
|
||||
// Find the matching closing paranthesis, or returns None on failure
|
||||
let paren_end_index = paren_start_idx + text[paren_start_idx..].find(')')?;
|
||||
|
||||
// Extract the content between parentheses, and parse valid line ranges
|
||||
let paren_content = &text[paren_start_idx + 1..paren_end_index];
|
||||
let mut detected_ranges = Vec::new();
|
||||
|
||||
for potential_range in paren_content.split(',') {
|
||||
match parse_line_range(potential_range, text) {
|
||||
Ok(range) => detected_ranges.push(range),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
(!detected_ranges.is_empty()).then_some(detected_ranges)
|
||||
}
|
||||
|
||||
/// Pre-extracted hyperlinks keyed by text location. Each entry contains the char ranges
|
||||
/// and URL strings for markdown hyperlinks (e.g. `[text](url)`) found in that location.
|
||||
type HyperlinksByLocation = Vec<(TextLocation, Vec<(Range<usize>, String)>)>;
|
||||
|
||||
/// Collects all text/location pairs and markdown hyperlinks from an AI output.
|
||||
/// Only reads in-memory data (no filesystem I/O), safe to call on the main thread.
|
||||
/// The returned data is designed to be fed into `detect_all_links` on a background thread.
|
||||
/// Returns raw text (no MD formatting) with location to run link detection on, and markdown hyperlinks.
|
||||
pub(crate) fn collect_output_data_for_link_detection(
|
||||
output: &AIAgentOutput,
|
||||
current_working_directory: Option<&String>,
|
||||
shell_launch_data: Option<&ShellLaunchData>,
|
||||
) -> (Vec<(String, TextLocation)>, HyperlinksByLocation) {
|
||||
let mut texts = Vec::new();
|
||||
let mut hyperlinks = Vec::new();
|
||||
|
||||
// Collect action texts (ReadFiles requests)
|
||||
for (action_index, action) in output.actions().enumerate() {
|
||||
if let AIAgentActionType::ReadFiles(ReadFilesRequest { locations }) = &action.action {
|
||||
for (line_index, file_location) in locations.iter().enumerate() {
|
||||
texts.push((
|
||||
file_location.to_user_message(
|
||||
shell_launch_data,
|
||||
current_working_directory,
|
||||
None,
|
||||
),
|
||||
TextLocation::Action {
|
||||
action_index,
|
||||
line_index,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect output text sections and extract hyperlinks from formatted lines
|
||||
for (section_index, section) in output
|
||||
.all_text()
|
||||
.flat_map(|text| text.sections.iter())
|
||||
.enumerate()
|
||||
{
|
||||
match section {
|
||||
AIAgentTextSection::PlainText { text } => match &text.formatted_lines {
|
||||
Some(formatted_lines) => {
|
||||
for (line_index, line) in formatted_lines.lines().iter().enumerate() {
|
||||
let location = TextLocation::Output {
|
||||
section_index,
|
||||
line_index,
|
||||
};
|
||||
texts.push((line.raw_text().to_owned(), location));
|
||||
|
||||
let url_hyperlinks = line.hyperlinks();
|
||||
if !url_hyperlinks.is_empty() {
|
||||
hyperlinks.push((location, url_hyperlinks));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
texts.push((
|
||||
text.text().to_owned(),
|
||||
TextLocation::Output {
|
||||
section_index,
|
||||
line_index: 0,
|
||||
},
|
||||
));
|
||||
}
|
||||
},
|
||||
AIAgentTextSection::Image { image } => {
|
||||
texts.push((
|
||||
image.markdown_source.clone(),
|
||||
TextLocation::Output {
|
||||
section_index,
|
||||
line_index: 0,
|
||||
},
|
||||
));
|
||||
texts.push((
|
||||
image.source.clone(),
|
||||
TextLocation::Output {
|
||||
section_index,
|
||||
line_index: 1,
|
||||
},
|
||||
));
|
||||
}
|
||||
AIAgentTextSection::MermaidDiagram { diagram } => {
|
||||
texts.push((
|
||||
diagram.markdown_source.clone(),
|
||||
TextLocation::Output {
|
||||
section_index,
|
||||
line_index: 0,
|
||||
},
|
||||
));
|
||||
}
|
||||
AIAgentTextSection::Code { .. } | AIAgentTextSection::Table { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
(texts, hyperlinks)
|
||||
}
|
||||
|
||||
/// Runs URL and file path detection on the given texts and combines with pre-extracted markdown hyperlinks.
|
||||
/// Designed to run on a background thread (file path detection does filesystem I/O).
|
||||
pub(crate) fn detect_all_links(
|
||||
texts: &[(String, TextLocation)],
|
||||
md_hyperlinks: HyperlinksByLocation,
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
current_working_directory: Option<&String>,
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] shell_launch_data: Option<
|
||||
&ShellLaunchData,
|
||||
>,
|
||||
) -> HashMap<TextLocation, HashMap<Range<usize>, DetectedLinkType>> {
|
||||
let mut all_links: HashMap<TextLocation, HashMap<Range<usize>, DetectedLinkType>> =
|
||||
HashMap::new();
|
||||
|
||||
for (text, location) in texts {
|
||||
let url_ranges = detect_urls(text);
|
||||
let mut links = HashMap::new();
|
||||
|
||||
// Detect URLs via regex
|
||||
for url_range in &url_ranges {
|
||||
if let Some(link_text) = char_slice(text, url_range.start, url_range.end) {
|
||||
links.insert(
|
||||
url_range.clone(),
|
||||
DetectedLinkType::Url(link_text.to_owned()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect file path links, skipping any that overlap with URLs
|
||||
#[cfg(feature = "local_fs")]
|
||||
if let Some(cwd) = current_working_directory {
|
||||
let file_paths = detect_file_paths(cwd, text, shell_launch_data);
|
||||
for (range, link) in file_paths {
|
||||
if !url_ranges
|
||||
.iter()
|
||||
.any(|ur| ur.start < range.end && range.start < ur.end)
|
||||
{
|
||||
links.insert(range, link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !links.is_empty() {
|
||||
all_links.insert(*location, links);
|
||||
}
|
||||
}
|
||||
|
||||
// Add hyperlinks extracted from formatted markdown text
|
||||
for (location, line_hyperlinks) in md_hyperlinks {
|
||||
let entry = all_links.entry(location).or_default();
|
||||
for (range, url) in line_hyperlinks {
|
||||
entry.insert(range, DetectedLinkType::Url(url));
|
||||
}
|
||||
}
|
||||
|
||||
all_links
|
||||
}
|
||||
|
||||
/// Given some text and its location
|
||||
/// the detected_links_state.
|
||||
pub(crate) fn detect_links(
|
||||
detected_links_state: &mut DetectedLinksState,
|
||||
text: &str,
|
||||
text_location: TextLocation,
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
current_working_directory: Option<&String>,
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] shell_launch_data: Option<
|
||||
&ShellLaunchData,
|
||||
>,
|
||||
) {
|
||||
let url_ranges = detect_urls(text);
|
||||
for url_range in &url_ranges {
|
||||
let Some(link) = char_slice(text, url_range.start, url_range.end) else {
|
||||
continue;
|
||||
};
|
||||
detected_links_state
|
||||
.detected_links_by_location
|
||||
.entry(text_location)
|
||||
.or_default()
|
||||
.detected_links
|
||||
.insert(
|
||||
url_range.clone(),
|
||||
HoverableDetectedLink {
|
||||
link: DetectedLinkType::Url(link.to_owned()),
|
||||
mouse_state: Default::default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "local_fs")]
|
||||
if let Some(current_working_directory) = current_working_directory {
|
||||
let file_paths = detect_file_paths(current_working_directory, text, shell_launch_data);
|
||||
for (range, link) in file_paths {
|
||||
// If this file path range overlaps with a URL range, don't add it.
|
||||
if url_ranges
|
||||
.iter()
|
||||
.any(|url_range| url_range.start < range.end && range.start < url_range.end)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
detected_links_state
|
||||
.detected_links_by_location
|
||||
.entry(text_location)
|
||||
.or_default()
|
||||
.detected_links
|
||||
.insert(
|
||||
range,
|
||||
HoverableDetectedLink {
|
||||
link,
|
||||
mouse_state: Default::default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "link_detection_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,75 @@
|
||||
use super::*;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word() {
|
||||
let word = "/path/to/file:16:hello";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/to/file:16:hello",
|
||||
"/path/to/file:16",
|
||||
"/path/to/file",
|
||||
"16:hello",
|
||||
"hello",
|
||||
"16"
|
||||
]
|
||||
);
|
||||
|
||||
let word = "/path/to/file:162:47.";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/to/file:162:47.",
|
||||
"/path/to/file:162:47",
|
||||
"/path/to/file:162",
|
||||
"/path/to/file",
|
||||
"162:47.",
|
||||
"162:47",
|
||||
"162",
|
||||
"47.",
|
||||
"47"
|
||||
]
|
||||
);
|
||||
|
||||
let word = "<Cargo.toml:16:4>";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"<Cargo.toml:16:4>",
|
||||
"<Cargo.toml:16:4",
|
||||
"Cargo.toml:16:4>",
|
||||
"Cargo.toml:16:4",
|
||||
"<Cargo.toml:16",
|
||||
"Cargo.toml:16",
|
||||
"<Cargo.toml",
|
||||
"Cargo.toml",
|
||||
"16:4>",
|
||||
"16:4",
|
||||
"16",
|
||||
"4>",
|
||||
"4"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_possible_file_paths_in_word_multibyte() {
|
||||
let word = "/path/音楽/テストファイル.txt:16:ḧeĹḹo";
|
||||
let possible_paths = possible_file_paths_in_word(word).collect_vec();
|
||||
assert_eq!(
|
||||
possible_paths,
|
||||
vec![
|
||||
"/path/音楽/テストファイル.txt:16:ḧeĹḹo",
|
||||
"/path/音楽/テストファイル.txt:16",
|
||||
"/path/音楽/テストファイル.txt",
|
||||
"16:ḧeĹḹo",
|
||||
"ḧeĹḹo",
|
||||
"16"
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use crate::channel::ChannelState;
|
||||
|
||||
pub const USER_DOCS_URL: &str = "https://docs.warp.dev/";
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub const GITHUB_ISSUES_URL: &str = "https://github.com/warpdotdev/Warp/issues";
|
||||
pub const SLACK_URL: &str = "http://go.warp.dev/join-preview";
|
||||
pub const PRIVACY_POLICY_URL: &str = "https://www.warp.dev/privacy";
|
||||
|
||||
pub fn feedback_form_url() -> String {
|
||||
let mut url = url::Url::parse("https://github.com/warpdotdev/Warp/issues/new/choose")
|
||||
.expect("Should not fail to parse");
|
||||
if let Some(version) = ChannelState::app_version() {
|
||||
url.query_pairs_mut().append_pair("warp-version", version);
|
||||
}
|
||||
url.query_pairs_mut()
|
||||
.append_pair("os-version", &os_info::get().version().to_string());
|
||||
url.to_string()
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash)]
|
||||
pub struct Span {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
impl From<(usize, usize)> for Span {
|
||||
fn from((start, end): (usize, usize)) -> Span {
|
||||
Span::new(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Span> for Span {
|
||||
fn from(span: &Span) -> Span {
|
||||
*span
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<Span>> for Span {
|
||||
fn from(input: Option<Span>) -> Span {
|
||||
input.unwrap_or_else(|| Span::new(0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Span> for std::ops::Range<usize> {
|
||||
fn from(input: Span) -> std::ops::Range<usize> {
|
||||
let start = input.start;
|
||||
let end = input.end;
|
||||
|
||||
std::ops::Range { start, end }
|
||||
}
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// Creates a new `Span` that has 0 start and 0 end.
|
||||
pub fn unknown() -> Span {
|
||||
Span::new(0, 0)
|
||||
}
|
||||
|
||||
pub fn for_char(pos: usize) -> Span {
|
||||
Span {
|
||||
start: pos,
|
||||
end: pos + 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn until(&self, other: impl Into<Span>) -> Span {
|
||||
let other = other.into();
|
||||
|
||||
Span::new(self.start, other.end)
|
||||
}
|
||||
|
||||
pub fn from_list(list: &[impl HasSpan]) -> Span {
|
||||
let mut iterator = list.iter();
|
||||
|
||||
match iterator.next() {
|
||||
None => Span::new(0, 0),
|
||||
Some(first) => {
|
||||
let last = iterator.last().unwrap_or(first);
|
||||
|
||||
Span::new(first.span().start, last.span().end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(start: usize, end: usize) -> Span {
|
||||
assert!(
|
||||
end >= start,
|
||||
"Can't create a Span whose end < start, start={start}, end={end}"
|
||||
);
|
||||
|
||||
Span { start, end }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.start == self.end
|
||||
}
|
||||
|
||||
pub fn skip(&self, n_chars: usize) -> Span {
|
||||
Span::new(self.start + n_chars, self.end)
|
||||
}
|
||||
|
||||
pub fn distance(&self) -> usize {
|
||||
self.end - self.start
|
||||
}
|
||||
|
||||
pub fn start(&self) -> usize {
|
||||
self.start
|
||||
}
|
||||
|
||||
pub fn end(&self) -> usize {
|
||||
self.end
|
||||
}
|
||||
|
||||
pub fn slice<'a>(&self, source: &'a str) -> &'a str {
|
||||
let start = self.start;
|
||||
let end = self.end;
|
||||
|
||||
&source[start..end]
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd<usize> for Span {
|
||||
fn partial_cmp(&self, other: &usize) -> Option<Ordering> {
|
||||
(self.end - self.start).partial_cmp(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<usize> for Span {
|
||||
fn eq(&self, other: &usize) -> bool {
|
||||
(self.end - self.start) == *other
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||
pub struct Spanned<T> {
|
||||
pub span: Span,
|
||||
pub item: T,
|
||||
}
|
||||
|
||||
impl<T> Spanned<T> {
|
||||
pub fn map<U>(self, input: impl FnOnce(T) -> U) -> Spanned<U> {
|
||||
let span = self.span;
|
||||
|
||||
let mapped = input(self.item);
|
||||
mapped.spanned(span)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SpannedItem: Sized {
|
||||
fn spanned(self, span: impl Into<Span>) -> Spanned<Self> {
|
||||
Spanned {
|
||||
item: self,
|
||||
span: span.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn spanned_unknown(self) -> Spanned<Self> {
|
||||
Spanned {
|
||||
item: self,
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SpannedItem for T {}
|
||||
|
||||
impl<T> std::ops::Deref for Spanned<T> {
|
||||
type Target = T;
|
||||
|
||||
/// Shorthand to deref to the contained value
|
||||
fn deref(&self) -> &T {
|
||||
&self.item
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HasSpan {
|
||||
fn span(&self) -> Span;
|
||||
}
|
||||
|
||||
impl<T, E> HasSpan for Result<T, E>
|
||||
where
|
||||
T: HasSpan,
|
||||
{
|
||||
fn span(&self) -> Span {
|
||||
match self {
|
||||
Result::Ok(val) => val.span(),
|
||||
Result::Err(_) => Span::unknown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> HasSpan for Spanned<T> {
|
||||
fn span(&self) -> Span {
|
||||
self.span
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoSpanned {
|
||||
type Output: HasFallibleSpan;
|
||||
|
||||
fn into_spanned(self, span: impl Into<Span>) -> Self::Output;
|
||||
}
|
||||
|
||||
impl<T: HasFallibleSpan> IntoSpanned for T {
|
||||
type Output = T;
|
||||
fn into_spanned(self, _span: impl Into<Span>) -> Self::Output {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait HasFallibleSpan {
|
||||
fn maybe_span(&self) -> Option<Span>;
|
||||
}
|
||||
|
||||
impl HasFallibleSpan for bool {
|
||||
fn maybe_span(&self) -> Option<Span> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl HasFallibleSpan for () {
|
||||
fn maybe_span(&self) -> Option<Span> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> HasFallibleSpan for T
|
||||
where
|
||||
T: HasSpan,
|
||||
{
|
||||
fn maybe_span(&self) -> Option<Span> {
|
||||
Some(HasSpan::span(self))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "meta_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,112 @@
|
||||
pub mod bindings;
|
||||
pub mod clipboard;
|
||||
pub mod color;
|
||||
pub mod extensions;
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub mod file;
|
||||
pub mod git;
|
||||
pub mod image;
|
||||
pub(crate) mod link_detection;
|
||||
pub mod links;
|
||||
pub mod openable_file_type;
|
||||
#[cfg(feature = "local_tty")]
|
||||
pub mod path;
|
||||
pub mod sync;
|
||||
pub mod time_format;
|
||||
pub mod tooltips;
|
||||
pub(crate) mod traffic_lights;
|
||||
pub(crate) mod truncation;
|
||||
pub mod vm_detection;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
use itertools::Itertools;
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::ops::Range;
|
||||
|
||||
pub fn merge_ranges(mut ranges: Vec<Range<usize>>) -> Vec<Range<usize>> {
|
||||
let mut i = 1;
|
||||
while i < ranges.len() {
|
||||
if ranges[i - 1].end.cmp(&ranges[i].start) >= Ordering::Equal {
|
||||
let removed = ranges.remove(i);
|
||||
if removed.start.cmp(&ranges[i - 1].start) < Ordering::Equal {
|
||||
ranges[i - 1].start = removed.start;
|
||||
}
|
||||
if removed.end.cmp(&ranges[i - 1].end) > Ordering::Equal {
|
||||
ranges[i - 1].end = removed.end;
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
pub fn dedupe_from_last(lines: Vec<String>) -> Vec<String> {
|
||||
let mut unique_elements = lines.into_iter().rev().unique().collect::<Vec<_>>();
|
||||
unique_elements.reverse();
|
||||
unique_elements
|
||||
}
|
||||
|
||||
pub fn parse_ascii_u32(bytes: &[u8]) -> Option<u32> {
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result: u32 = 0;
|
||||
for &byte in bytes {
|
||||
if !byte.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
result = result.checked_mul(10)?.checked_add((byte - b'0') as u32)?;
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// AsciiDebug is intended to make it easy to inspect the contents of byte slices that are mostly ASCII
|
||||
/// characters (but may not be valid unicode). It changes the output of the wrapped byte slice to
|
||||
/// a human readable string with non-ASCII characters written as hex escapes.
|
||||
///
|
||||
/// E.g. `log::info!("{:?}", &AsciiDebug(some_byte_slice));`
|
||||
pub struct AsciiDebug<'a>(pub &'a [u8]);
|
||||
|
||||
impl fmt::Debug for AsciiDebug<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "\"")?;
|
||||
for &byte in self.0 {
|
||||
// Check if the byte is a standard printable charcter.
|
||||
if (32..126).contains(&byte) {
|
||||
write!(f, "{}", byte as char)?;
|
||||
} else {
|
||||
write!(f, "\\{{{byte:02X}}}")?;
|
||||
}
|
||||
}
|
||||
write!(f, "\"")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedupe() {
|
||||
let history_lines = vec![
|
||||
"1".to_string(),
|
||||
"3".to_string(),
|
||||
"2".to_string(),
|
||||
"1".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
dedupe_from_last(history_lines),
|
||||
vec!["3".to_string(), "2".to_string(), "1".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ascii_u32() {
|
||||
assert_eq!(parse_ascii_u32(b"123"), Some(123));
|
||||
assert_eq!(parse_ascii_u32(b"0"), Some(0));
|
||||
assert_eq!(parse_ascii_u32(b"4294967295"), Some(4294967295)); // Max u32
|
||||
assert_eq!(parse_ascii_u32(b"4294967296"), None); // Overflow
|
||||
assert_eq!(parse_ascii_u32(b""), None);
|
||||
assert_eq!(parse_ascii_u32(b"12a3"), None);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! File type detection utilities for determining if files can be opened in Warp.
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::file::external_editor::{settings::EditorChoice, Editor, EditorSettings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
pub use warp_util::file_type::{is_binary_file, is_file_content_binary, is_markdown_file};
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Layout used when opening files in the editor.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum EditorLayout {
|
||||
SplitPane,
|
||||
NewTab,
|
||||
}
|
||||
|
||||
/// The type of file that can be opened in Warp. The in-product treatment for "opening" a file
|
||||
/// depends on its type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OpenableFileType {
|
||||
/// A Markdown file, which should be opened in a Markdown viewer pane.
|
||||
Markdown,
|
||||
/// A code file, which should be opened in a code editor pane.
|
||||
Code,
|
||||
/// Other types of text files, e.g. txt, csv, svg files, which can still be opened in a code editor pane.
|
||||
Text,
|
||||
}
|
||||
|
||||
/// The target application or viewer to use when opening a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FileTarget {
|
||||
/// Open in Warp's Markdown viewer.
|
||||
MarkdownViewer(EditorLayout),
|
||||
/// Open in Warp's Code Editor.
|
||||
CodeEditor(EditorLayout),
|
||||
/// Open in an external editor (e.g. VS Code, Emacs).
|
||||
#[cfg(feature = "local_fs")]
|
||||
ExternalEditor(Editor),
|
||||
/// Open in the environment editor ($EDITOR).
|
||||
EnvEditor,
|
||||
/// Open in the system default application.
|
||||
SystemDefault,
|
||||
/// Open in the system default application (generic open, e.g. for binary files).
|
||||
SystemGeneric,
|
||||
}
|
||||
|
||||
/// Checks if a file is a code file with language support.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn is_supported_code_file(path: impl AsRef<Path>) -> bool {
|
||||
let path = path.as_ref();
|
||||
languages::language_by_filename(path).is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
pub fn is_supported_code_file(_path: impl AsRef<Path>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_supported_image_file(path: impl AsRef<Path>) -> bool {
|
||||
path.as_ref()
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"jpg" | "jpeg" | "png" | "gif" | "webp" | "svg"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Determines if a file can be opened in Warp and returns its type.
|
||||
/// Returns `None` if the file is binary and should not be opened.
|
||||
pub fn is_file_openable_in_warp(path: &Path) -> Option<OpenableFileType> {
|
||||
if is_binary_file(path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_markdown_file(path) {
|
||||
Some(OpenableFileType::Markdown)
|
||||
} else if is_supported_code_file(path) {
|
||||
Some(OpenableFileType::Code)
|
||||
} else {
|
||||
// We allow opening the file, even if we don't have particular syntax highlighting support
|
||||
// for it e.g. txt files.
|
||||
Some(OpenableFileType::Text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Only use this for UI elements that must explicitly open a file in Warp (i.e. "Open in New Tab").
|
||||
/// Prefer `resolve_file_target` for all other cases to respect users' preferences.
|
||||
/// This would also force any binary file to be opened in Warp's Code Editor, so you should likely check
|
||||
/// `is_file_openable_in_warp` before rendering any such UI Elements.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn resolve_file_target_to_open_in_warp(
|
||||
path: &Path,
|
||||
settings: &EditorSettings,
|
||||
layout: Option<EditorLayout>,
|
||||
) -> FileTarget {
|
||||
let openable_file_type = is_file_openable_in_warp(path);
|
||||
let is_markdown = matches!(openable_file_type, Some(OpenableFileType::Markdown));
|
||||
let layout = layout.unwrap_or(*settings.open_file_layout);
|
||||
|
||||
if is_markdown && *settings.prefer_markdown_viewer {
|
||||
return FileTarget::MarkdownViewer(layout);
|
||||
}
|
||||
FileTarget::CodeEditor(layout)
|
||||
}
|
||||
|
||||
/// Resolves the target application or viewer for opening a file based on its path and editor settings.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn resolve_file_target(
|
||||
path: &Path,
|
||||
settings: &EditorSettings,
|
||||
layout: Option<EditorLayout>,
|
||||
) -> FileTarget {
|
||||
resolve_file_target_with_editor_choice(
|
||||
path,
|
||||
*settings.open_file_editor,
|
||||
*settings.prefer_markdown_viewer,
|
||||
*settings.open_file_layout,
|
||||
layout,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn resolve_file_target_with_editor_choice(
|
||||
path: &Path,
|
||||
editor_choice: EditorChoice,
|
||||
prefer_markdown_viewer: bool,
|
||||
default_layout: EditorLayout,
|
||||
layout: Option<EditorLayout>,
|
||||
) -> FileTarget {
|
||||
let is_openable_in_warp = is_file_openable_in_warp(path);
|
||||
let is_markdown = matches!(is_openable_in_warp, Some(OpenableFileType::Markdown));
|
||||
let layout = layout.unwrap_or(default_layout);
|
||||
let is_openable_in_warp = is_openable_in_warp.is_some();
|
||||
|
||||
// 1. Markdown Viewer (only if user preference specified)
|
||||
if is_markdown && prefer_markdown_viewer {
|
||||
return FileTarget::MarkdownViewer(layout);
|
||||
}
|
||||
|
||||
// 2. Warp Code Editor (Explicit user preference)
|
||||
if is_openable_in_warp && matches!(editor_choice, EditorChoice::Warp) {
|
||||
return FileTarget::CodeEditor(layout);
|
||||
}
|
||||
|
||||
// 3. Env Editor
|
||||
if matches!(editor_choice, EditorChoice::EnvEditor) {
|
||||
return FileTarget::EnvEditor;
|
||||
}
|
||||
|
||||
// 4. Binary files -> System Default
|
||||
if !is_openable_in_warp {
|
||||
return FileTarget::SystemGeneric;
|
||||
}
|
||||
|
||||
// 5. External Editor or System Default (for text files)
|
||||
match editor_choice {
|
||||
EditorChoice::ExternalEditor(editor) => FileTarget::ExternalEditor(editor),
|
||||
EditorChoice::SystemDefault => FileTarget::SystemDefault,
|
||||
EditorChoice::Warp | EditorChoice::EnvEditor => unreachable!("Already matched above"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use settings::Setting as _;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_binary_files_not_openable() {
|
||||
assert!(is_file_openable_in_warp(Path::new("image.png")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("video.mp4")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("binary.exe")).is_none());
|
||||
assert!(is_file_openable_in_warp(Path::new("archive.zip")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_open_code_panels_file_editor_default_is_warp() {
|
||||
use crate::util::file::external_editor::settings::OpenCodePanelsFileEditor;
|
||||
|
||||
assert_eq!(
|
||||
OpenCodePanelsFileEditor::default_value(),
|
||||
EditorChoice::Warp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_markdown_viewer_precedence() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("README.md"),
|
||||
EditorChoice::ExternalEditor(Editor::VSCode),
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::MarkdownViewer(EditorLayout::SplitPane));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_warp_uses_default_layout() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("data.txt"),
|
||||
EditorChoice::Warp,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::NewTab,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::CodeEditor(EditorLayout::NewTab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_binary_is_system_generic() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("image.png"),
|
||||
EditorChoice::Warp,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(target, FileTarget::SystemGeneric);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_resolve_file_target_binary_uses_env_editor() {
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
Path::new("image.png"),
|
||||
EditorChoice::EnvEditor,
|
||||
true, /* prefer_markdown_viewer */
|
||||
EditorLayout::SplitPane,
|
||||
None,
|
||||
);
|
||||
assert_eq!(target, FileTarget::EnvEditor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("README.md")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("doc.markdown")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("README")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("CHANGELOG")),
|
||||
Some(OpenableFileType::Markdown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_code_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("main.rs")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("app.js")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("script.py")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("config.json")),
|
||||
Some(OpenableFileType::Code)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
fn test_code_files() {
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("main.rs")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("app.js")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("script.py")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("config.json")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_files() {
|
||||
// Files that are text but don't have language support
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("data.txt")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("data.csv")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
assert_eq!(
|
||||
is_file_openable_in_warp(Path::new("file.svg")),
|
||||
Some(OpenableFileType::Text)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_supported_code_file() {
|
||||
assert!(is_supported_code_file(Path::new("main.rs")));
|
||||
assert!(is_supported_code_file(Path::new("app.js")));
|
||||
assert!(is_supported_code_file(Path::new("script.py")));
|
||||
assert!(!is_supported_code_file(Path::new("data.txt")));
|
||||
assert!(!is_supported_code_file(Path::new("image.png")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
env,
|
||||
ffi::OsStr,
|
||||
path::{self, Path},
|
||||
};
|
||||
|
||||
use is_executable::IsExecutable as _;
|
||||
use itertools::Itertools as _;
|
||||
|
||||
pub fn file_exists_and_is_executable(path: &Path) -> bool {
|
||||
// We need to check that the file exists, as the `is_executable` crate doesn't validate this on
|
||||
// Windows.
|
||||
path.is_file() && path.is_executable()
|
||||
}
|
||||
|
||||
/// Resolves `command` into an executable path, matching the shell's search behavior.
|
||||
/// If the command contains a path separator, it should resolve to an executable
|
||||
/// file. Otherwise, it should exist in the process's `PATH`.
|
||||
///
|
||||
/// Callers that need to resolve against a different PATH (e.g. one
|
||||
/// captured from the user's interactive login shell) should use
|
||||
/// [`resolve_executable_in_path`] directly.
|
||||
pub fn resolve_executable(command: &str) -> Option<Cow<'_, Path>> {
|
||||
let path_var = env::var_os("PATH").unwrap_or_default();
|
||||
resolve_executable_in_path(command, &path_var)
|
||||
}
|
||||
|
||||
/// Like [`resolve_executable`], but resolves PATH-based lookups against
|
||||
/// the given `path_env` instead of the process's own `PATH`.
|
||||
///
|
||||
/// Intended for callers that have a specific PATH to search (e.g. one
|
||||
/// captured from the user's interactive login shell, matching how
|
||||
/// MCP/LSP find binaries). Callers that want the process's PATH should
|
||||
/// use [`resolve_executable`] instead.
|
||||
pub fn resolve_executable_in_path<'a>(command: &'a str, path_env: &OsStr) -> Option<Cow<'a, Path>> {
|
||||
if command.contains(path::MAIN_SEPARATOR) {
|
||||
let path = Path::new(command);
|
||||
return file_exists_and_is_executable(path).then_some(Cow::Borrowed(path));
|
||||
}
|
||||
for path_dir in env::split_paths(path_env).unique() {
|
||||
let resolved = path_dir.join(command);
|
||||
if file_exists_and_is_executable(&resolved) {
|
||||
return Some(Cow::Owned(resolved));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::util::extensions::TrimStringExt;
|
||||
|
||||
#[test]
|
||||
fn test_trim_newline() {
|
||||
let mut string = "".to_string();
|
||||
string.trim_trailing_newline();
|
||||
assert_eq!("", string);
|
||||
|
||||
let mut string = "test\n".to_string();
|
||||
string.trim_trailing_newline();
|
||||
assert_eq!("test", string);
|
||||
|
||||
let mut string = " test \n".to_string();
|
||||
string.trim_trailing_newline();
|
||||
assert_eq!(" test ", string);
|
||||
}
|
||||
|
||||
/// TODO(CORE-3626): write an equivalent test with Windows paths.
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn test_resolve_command() {
|
||||
use crate::util::path::resolve_executable;
|
||||
use std::path::Path;
|
||||
|
||||
assert_eq!(
|
||||
&resolve_executable("/bin/sh").unwrap(),
|
||||
Path::new("/bin/sh")
|
||||
);
|
||||
assert_eq!(
|
||||
&resolve_executable("env").unwrap(),
|
||||
Path::new("/usr/bin/env")
|
||||
);
|
||||
// This path exists in the Warp repo, so it should resolve. The `../`
|
||||
// is because Rust unit tests run from the root of the crate (`app` in
|
||||
// this case).
|
||||
assert_eq!(
|
||||
&resolve_executable("../script/run").unwrap(),
|
||||
Path::new("../script/run")
|
||||
);
|
||||
// `pwd` should always exist (it's also a shell builtin), but we won't
|
||||
// assume a specific location.
|
||||
assert!(resolve_executable("pwd").is_some());
|
||||
assert!(resolve_executable("unlikely-command").is_none());
|
||||
assert!(resolve_executable("nonexistent/relative/path").is_none());
|
||||
// src/main.rs does exist, but is not executable.
|
||||
assert!(resolve_executable("src/main.rs").is_none());
|
||||
// Note the trailing space.
|
||||
assert!(resolve_executable("zsh ").is_none());
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Synchronization utilities.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use event_listener::Event;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sync_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// A set-once asynchronous condition variable.
|
||||
///
|
||||
/// Generally, a [condition variable](http://www.cs.cornell.edu/courses/cs3110/2012fa/recitations/rec16.html)
|
||||
/// lets tasks wait until some condition becomes true (for example, we might want to wait for the
|
||||
/// user to have logged in, or for the initial load of Warp Drive objects to have finished). When
|
||||
/// the condition becomes true, one or all of the waiting tasks can wake up and do their work.
|
||||
///
|
||||
/// This [`Condition`] implementation models the simpler case where a condition becomes true and
|
||||
/// is then *always* true (unless reset explicitly). This allows waiting for something to happen at least once. If a task
|
||||
/// starts waiting before the condition is met, it will block, but if the condition is already true,
|
||||
/// it continues immediately.
|
||||
///
|
||||
/// We use this in Warp Drive to wait for the initial load of changed objects to finish. Regular UI
|
||||
/// framework events aren't suitable, because they don't tell us if the load had *already*
|
||||
/// finished - a task that subscribed too late would block forever!
|
||||
///
|
||||
/// Also see [`std::sync::Condvar`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Condition {
|
||||
// This is more or less the reference example for async-listener:
|
||||
// https://github.com/smol-rs/event-listener
|
||||
flag: Arc<AtomicBool>,
|
||||
event: Arc<Event>,
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
flag: Arc::new(AtomicBool::new(false)),
|
||||
event: Arc::new(Event::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the condition as true.
|
||||
pub fn set(&self) {
|
||||
self.flag.store(true, Ordering::SeqCst);
|
||||
self.event.notify(usize::MAX);
|
||||
}
|
||||
|
||||
/// Reset the condition to false so that future [`wait`](Self::wait) calls
|
||||
/// will block until [`set`](Self::set) is called again.
|
||||
pub fn reset(&self) {
|
||||
self.flag.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Returns `true` if the condition has already been set.
|
||||
pub fn is_set(&self) -> bool {
|
||||
self.flag.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Asynchronously wait for the condition to be true.
|
||||
pub fn wait(&self) -> impl Future<Output = ()> {
|
||||
let flag = self.flag.clone();
|
||||
let event = self.event.clone();
|
||||
async move {
|
||||
// Loop in case of spurious wakeups.
|
||||
loop {
|
||||
// Check if the condition has already been set.
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
let listener = event.listen();
|
||||
|
||||
// Check the flag again after creating the listener, in case it was set while we
|
||||
// started listening.
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
listener.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Condition {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use futures_lite::future;
|
||||
|
||||
use super::Condition;
|
||||
|
||||
#[test]
|
||||
fn test_condition_multiple_waiters() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
|
||||
let mut listener1 = Box::pin(condition.wait());
|
||||
let mut listener2 = Box::pin(condition.wait());
|
||||
|
||||
// Neither listener should be ready.
|
||||
assert!(future::poll_once(&mut listener1).await.is_none());
|
||||
assert!(future::poll_once(&mut listener2).await.is_none());
|
||||
|
||||
condition.set();
|
||||
|
||||
// Now, both should complete.
|
||||
assert!(future::poll_once(listener1).await.is_some());
|
||||
assert!(future::poll_once(listener2).await.is_some());
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_after_set() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
condition.set();
|
||||
|
||||
// After the condition is set, waiting should complete immediately.
|
||||
assert!(future::poll_once(condition.wait()).await.is_some());
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condition_multiple_sets() {
|
||||
future::block_on(async {
|
||||
let condition = Condition::new();
|
||||
|
||||
// Test that multiple interleavings of `wait` and `set` all complete as expected.
|
||||
let first = condition.wait();
|
||||
condition.set();
|
||||
let second = condition.wait();
|
||||
condition.set();
|
||||
let third = condition.wait();
|
||||
|
||||
assert!(future::poll_once(first).await.is_some());
|
||||
assert!(future::poll_once(second).await.is_some());
|
||||
assert!(future::poll_once(third).await.is_some());
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use chrono::{DateTime, Duration, Local, Utc};
|
||||
use std::ops::Sub;
|
||||
|
||||
// Some conversion ratios for time units.
|
||||
const SEC_TO_MS: f64 = 1000.;
|
||||
const MIN_TO_MS: f64 = 60. * SEC_TO_MS;
|
||||
const HOUR_TO_MS: f64 = 60. * MIN_TO_MS;
|
||||
const DAY_TO_MS: f64 = 24. * HOUR_TO_MS;
|
||||
const WEEK_TO_MS: f64 = 7. * DAY_TO_MS;
|
||||
const MONTH_TO_MS: f64 = 30.44 * DAY_TO_MS;
|
||||
const YEAR_TO_MS: f64 = 365.25 * DAY_TO_MS;
|
||||
|
||||
/// Subtract a given DateTime from now and format the duration is a concise, approximated,
|
||||
/// human-readable form. e.g. "just now"
|
||||
pub fn format_approx_duration_from_now(datetime: DateTime<Local>) -> String {
|
||||
human_readable_approx_duration(Local::now().sub(datetime), false)
|
||||
}
|
||||
|
||||
/// Subtract a given DateTime from now and format the duration is a concise, approximated,
|
||||
/// human-readable form. e.g. "Just now"
|
||||
pub fn format_approx_duration_from_now_sentence_case(datetime: DateTime<Local>) -> String {
|
||||
human_readable_approx_duration(Local::now().sub(datetime), true)
|
||||
}
|
||||
|
||||
/// Takes a time in UTC and determines roughly how long ago it occurred.
|
||||
pub fn format_approx_duration_from_now_utc(datetime: DateTime<Utc>) -> String {
|
||||
human_readable_approx_duration(Utc::now().sub(datetime), false)
|
||||
}
|
||||
|
||||
/// Format a duration into a human-readable string, e.g. "3.14 sec".
|
||||
/// Compared to [`human_readable_approx_duration`], this method is for higher-precision, smaller
|
||||
/// values.
|
||||
pub fn human_readable_precise_duration(duration: Duration) -> String {
|
||||
let ms = duration.num_milliseconds() as f64;
|
||||
let weeks = ms / WEEK_TO_MS;
|
||||
if weeks >= 1. {
|
||||
return String::from(">1 week");
|
||||
}
|
||||
let days = ms / DAY_TO_MS;
|
||||
if days >= 1. {
|
||||
return format!("{} days", format_sigfigs(days, 3));
|
||||
}
|
||||
let hours = ms / HOUR_TO_MS;
|
||||
if hours >= 1. {
|
||||
return format!("{} hours", format_sigfigs(hours, 3));
|
||||
}
|
||||
let minutes = ms / MIN_TO_MS;
|
||||
if minutes >= 1. {
|
||||
return format!("{} min", format_sigfigs(minutes, 3));
|
||||
}
|
||||
let seconds = ms / SEC_TO_MS;
|
||||
if seconds >= 1. {
|
||||
return format!("{} sec", format_sigfigs(seconds, 3));
|
||||
}
|
||||
format!("{} ms", duration.num_milliseconds())
|
||||
}
|
||||
|
||||
fn format_sigfigs(num: f64, sigfigs: usize) -> String {
|
||||
let a = num.abs();
|
||||
let precision = if a > 1. {
|
||||
let n = (1. + a.log10().floor()) as usize;
|
||||
sigfigs.saturating_sub(n)
|
||||
} else if a > 0. {
|
||||
let n = -(1. + a.log10().floor()) as usize;
|
||||
sigfigs + n
|
||||
} else {
|
||||
0
|
||||
};
|
||||
format!("{num:.precision$}")
|
||||
}
|
||||
|
||||
/// Format an approximated duration into a human-readable string, e.g. "2 days ago".
|
||||
/// Precision is limited to the most significant unit, i.e. 2 days and _n_ hours always displays
|
||||
/// simply as "2 days ago".
|
||||
pub fn human_readable_approx_duration(duration: Duration, sentence_case: bool) -> String {
|
||||
let ms = duration.num_milliseconds() as f64;
|
||||
let years = ms / YEAR_TO_MS;
|
||||
if years >= 1. {
|
||||
return truncated_quantity_with_unit(years, "year");
|
||||
}
|
||||
let months = ms / MONTH_TO_MS;
|
||||
if months >= 1. {
|
||||
return truncated_quantity_with_unit(months, "month");
|
||||
}
|
||||
let weeks = ms / WEEK_TO_MS;
|
||||
if weeks >= 1. {
|
||||
return truncated_quantity_with_unit(weeks, "week");
|
||||
}
|
||||
let days = ms / DAY_TO_MS;
|
||||
if days >= 1. {
|
||||
return truncated_quantity_with_unit(days, "day");
|
||||
}
|
||||
let hours = ms / HOUR_TO_MS;
|
||||
if hours >= 1. {
|
||||
return truncated_quantity_with_unit(hours, "hour");
|
||||
}
|
||||
// Minutes and seconds are both abbreviated, so skip pluralization.
|
||||
let minutes = ms / MIN_TO_MS;
|
||||
if minutes >= 1. {
|
||||
return format!("{} min ago", minutes as i32);
|
||||
}
|
||||
if sentence_case {
|
||||
"Just now".to_owned()
|
||||
} else {
|
||||
"just now".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provided a value and a unit, this will format the quantity as an integer number with the
|
||||
/// unit pluralized if the value is not 1.
|
||||
fn truncated_quantity_with_unit(num: f64, unit: &str) -> String {
|
||||
let truncated_int = num as i32;
|
||||
if truncated_int == 1 {
|
||||
format!("{truncated_int} {unit} ago")
|
||||
} else {
|
||||
format!("{truncated_int} {unit}s ago")
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a monotonic `Instant` as a human-readable relative timestamp.
|
||||
/// (Uses `Instant` rather than wall-clock `DateTime` for elapsed-time display.)
|
||||
pub fn format_elapsed_since(created_at: instant::Instant) -> String {
|
||||
let secs = created_at.elapsed().as_secs();
|
||||
|
||||
if secs < 60 {
|
||||
"Just now".to_string()
|
||||
} else if secs < 3600 {
|
||||
let mins = secs / 60;
|
||||
if mins == 1 {
|
||||
"1 minute ago".to_string()
|
||||
} else {
|
||||
format!("{mins} minutes ago")
|
||||
}
|
||||
} else if secs < 86400 {
|
||||
let hours = secs / 3600;
|
||||
if hours == 1 {
|
||||
"1 hour ago".to_string()
|
||||
} else {
|
||||
format!("{hours} hours ago")
|
||||
}
|
||||
} else {
|
||||
let days = secs / 86400;
|
||||
if days == 1 {
|
||||
"1 day ago".to_string()
|
||||
} else {
|
||||
format!("{days} days ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "time_format_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_sigfigs() {
|
||||
assert_eq!(format_sigfigs(0.000456, 2,), "0.00046");
|
||||
assert_eq!(format_sigfigs(0.043256, 3,), "0.0433");
|
||||
assert_eq!(format_sigfigs(0.01, 2,), "0.010");
|
||||
assert_eq!(format_sigfigs(10., 3,), "10.0");
|
||||
assert_eq!(format_sigfigs(456.719, 4,), "456.7");
|
||||
assert_eq!(format_sigfigs(10., 2,), "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_human_readable_precise_duration() {
|
||||
assert_eq!(
|
||||
human_readable_precise_duration(Duration::milliseconds(3)),
|
||||
"3 ms".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_precise_duration(Duration::milliseconds(10)),
|
||||
"10 ms".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_precise_duration(Duration::milliseconds(3141)),
|
||||
"3.14 sec".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_precise_duration(Duration::milliseconds(19961)),
|
||||
"20.0 sec".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_precise_duration(Duration::seconds(61)),
|
||||
"1.02 min".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_precise_duration(Duration::minutes(930)),
|
||||
"15.5 hours".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_precise_duration(Duration::hours(46)),
|
||||
"1.92 days".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_precise_duration(Duration::weeks(2)),
|
||||
">1 week".to_owned()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_human_readable_approx_duration() {
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::milliseconds(2), false),
|
||||
"just now".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::seconds(2), false),
|
||||
"just now".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::milliseconds(2), true),
|
||||
"Just now".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::seconds(2), true),
|
||||
"Just now".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::seconds(90), false),
|
||||
"1 min ago".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::minutes(100), false),
|
||||
"1 hour ago".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::minutes(130), false),
|
||||
"2 hours ago".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::days(4), false),
|
||||
"4 days ago".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::weeks(1), false),
|
||||
"1 week ago".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::weeks(15), false),
|
||||
"3 months ago".to_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
human_readable_approx_duration(Duration::weeks(520), false),
|
||||
"9 years ago".to_owned()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Shared tooltip UI components for file path and link tooltips
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::Path;
|
||||
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, Container, CornerRadius, Flex, MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, EventContext, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance, settings::PrivacySettings, terminal::model::secrets::SecretLevel,
|
||||
ui_components::blended_colors,
|
||||
};
|
||||
|
||||
/// A link to be shown in a tooltip
|
||||
pub struct TooltipLink<OnClick> {
|
||||
pub text: String,
|
||||
pub on_click: OnClick,
|
||||
/// Optional detail text to show after the link (e.g., "[Cmd Click]")
|
||||
pub detail: Option<String>,
|
||||
pub mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl<OnClick> TooltipLink<OnClick> {
|
||||
pub fn new(text: String, on_click: OnClick, mouse_state: MouseStateHandle) -> Self {
|
||||
Self {
|
||||
text,
|
||||
on_click,
|
||||
detail: None,
|
||||
mouse_state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_detail(mut self, detail: String) -> Self {
|
||||
self.detail = Some(detail);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for redaction messaging in tooltips
|
||||
pub enum TooltipRedaction {
|
||||
/// When sending text to an LLM, we want to ensure users this secret
|
||||
/// was obfuscated and not sent to the LLM.
|
||||
SecretNotSentToLLMMessaging {
|
||||
secret_level: Option<SecretLevel>,
|
||||
},
|
||||
/// When displaying text which is secret and could be added to an Agent Mode
|
||||
/// conversation, we want to ensure users this secret will not be sent to
|
||||
/// the LLM.
|
||||
SecretWillNotBeSentToLLMMessaging {
|
||||
secret_level: Option<SecretLevel>,
|
||||
},
|
||||
NoRedaction,
|
||||
}
|
||||
|
||||
/// Render a tooltip with one or more links and optional redaction messaging.
|
||||
///
|
||||
/// This is generic over the click handler type to support different action dispatch mechanisms.
|
||||
pub fn render_tooltip<OnClick>(
|
||||
tooltip_links: impl IntoIterator<Item = TooltipLink<OnClick>>,
|
||||
redaction: TooltipRedaction,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
OnClick: 'static + Fn(&mut EventContext),
|
||||
{
|
||||
let mut tooltip = Flex::column();
|
||||
let mut links = Vec::new();
|
||||
let mut first = true;
|
||||
let background_color = appearance.theme().tooltip_background();
|
||||
|
||||
for link in tooltip_links.into_iter() {
|
||||
if !first {
|
||||
links.push(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(" | ".to_string())
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let on_click = link.on_click;
|
||||
links.push(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.tooltip_link(
|
||||
link.text,
|
||||
None,
|
||||
Some(Box::new(move |ctx| {
|
||||
on_click(ctx);
|
||||
})),
|
||||
link.mouse_state,
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.build()
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(detail) = link.detail {
|
||||
links.push(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(detail)
|
||||
.with_style(UiComponentStyles {
|
||||
margin: Some(Coords::default().left(4.)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
first = false;
|
||||
}
|
||||
|
||||
let link_row = if links.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Flex::row().with_children(links).finish())
|
||||
};
|
||||
|
||||
match redaction {
|
||||
TooltipRedaction::SecretNotSentToLLMMessaging { secret_level }
|
||||
| TooltipRedaction::SecretWillNotBeSentToLLMMessaging { secret_level } => {
|
||||
let theme = appearance.theme();
|
||||
let title = if matches!(
|
||||
redaction,
|
||||
TooltipRedaction::SecretNotSentToLLMMessaging { .. }
|
||||
) {
|
||||
"This wasn't included in the AI conversation."
|
||||
} else {
|
||||
"This won't be included in any AI conversations or shared blocks."
|
||||
};
|
||||
|
||||
// Generate the appropriate message based on secret level
|
||||
let secret_message = match secret_level {
|
||||
Some(SecretLevel::Enterprise) => {
|
||||
"Pattern matched your organization's secret redaction regex list."
|
||||
}
|
||||
Some(SecretLevel::User) => "Pattern matched your secret redaction regex list.",
|
||||
None => "Pattern matched the secret redaction regex list.",
|
||||
};
|
||||
|
||||
tooltip.add_child(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Text::new(
|
||||
title,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() + 1.,
|
||||
)
|
||||
.with_color(theme.main_text_color(background_color.into()).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
secret_message,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(background_color.into()).into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(4.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
if let Some(link_row) = link_row {
|
||||
tooltip.add_child(Container::new(link_row).with_margin_top(4.).finish());
|
||||
}
|
||||
}
|
||||
TooltipRedaction::NoRedaction => {
|
||||
if let Some(link_row) = link_row {
|
||||
tooltip.add_child(link_row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let is_secret = matches!(
|
||||
redaction,
|
||||
TooltipRedaction::SecretNotSentToLLMMessaging { .. }
|
||||
| TooltipRedaction::SecretWillNotBeSentToLLMMessaging { .. }
|
||||
);
|
||||
|
||||
// If enterprise secret redaction is enabled, add additional messaging and padding to the tooltip.
|
||||
let is_enterprise_secret_redaction_enabled =
|
||||
is_secret && PrivacySettings::as_ref(app).is_enterprise_secret_redaction_enabled();
|
||||
let tooltip_element = if is_enterprise_secret_redaction_enabled {
|
||||
let tooltip_column = Flex::column()
|
||||
.with_child(tooltip.finish())
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span("*Secrets are not sent to Warp's server.")
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
margin: Some(Coords::default().top(4.)),
|
||||
font_color: Some(blended_colors::text_disabled(
|
||||
appearance.theme(),
|
||||
background_color,
|
||||
)),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(tooltip_column)
|
||||
.with_vertical_padding(4.)
|
||||
.with_horizontal_padding(6.)
|
||||
.finish()
|
||||
} else {
|
||||
Container::new(tooltip.finish())
|
||||
.with_vertical_padding(4.)
|
||||
.with_horizontal_padding(6.)
|
||||
.finish()
|
||||
};
|
||||
|
||||
Container::new(tooltip_element)
|
||||
.with_background(background_color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Returns whether "Open in Warp" should be offered for the given file path.
|
||||
///
|
||||
/// This checks:
|
||||
/// - Whether Warp is already the default editor (skip if so)
|
||||
/// - Whether this file is openable in Warp (skips binary files and directories)
|
||||
/// - Whether Warp is an OS-level default editor (skips Markdown files)
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn should_show_open_in_warp_link(path: &Path, app: &AppContext) -> bool {
|
||||
use crate::{
|
||||
code::view::is_binary_file,
|
||||
notebooks::file::is_markdown_file,
|
||||
util::file::external_editor::{settings::EditorChoice, EditorSettings},
|
||||
};
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
let settings = EditorSettings::as_ref(app);
|
||||
|
||||
if matches!(*settings.open_file_editor, EditorChoice::Warp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!is_markdown_file(path) && !is_binary_file(path) && !path.is_dir()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
pub fn should_show_open_in_warp_link(_path: &std::path::Path, _app: &AppContext) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
//! This module is meant to be a single source of truth for information about the windows' "traffic
|
||||
//! light" buttons, the minimize, maximize, and close buttons in the corner of the window, so named
|
||||
//! b/c of their resemblence to traffic lights on MacOS. How (whether or not) these are rendered
|
||||
//! depends on the platform. The Warp app must use this information to avoid rendering UI elements
|
||||
//! underneath them.
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_only {
|
||||
pub(super) use crate::workspace::TOTAL_TAB_BAR_HEIGHT;
|
||||
pub(super) use pathfinder_color::ColorU;
|
||||
pub(super) use pathfinder_geometry::vector::vec2f;
|
||||
pub(super) use std::sync::Arc;
|
||||
pub(super) use warpui::elements::{
|
||||
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Flex, Hoverable, Icon,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Rect, Stack,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use linux_only::*;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_only {
|
||||
pub(super) use crate::ui_components::icons::Icon as IconComponent;
|
||||
pub(super) use pathfinder_color::ColorU;
|
||||
pub(super) use pathfinder_geometry::vector::vec2f;
|
||||
pub(super) use warp_core::ui::theme;
|
||||
pub(super) use warpui::elements::{
|
||||
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, Hoverable,
|
||||
OffsetPositioning, ParentAnchor, ParentOffsetBounds, Radius, Rect, Stack,
|
||||
};
|
||||
pub(super) const WINDOWS_BRIGHT_RED: ColorU = ColorU {
|
||||
r: 232,
|
||||
g: 17,
|
||||
b: 32,
|
||||
a: u8::MAX,
|
||||
};
|
||||
|
||||
pub(super) const WINDOWS_BUTTON_PADDING_VERTICAL: f32 = 6.;
|
||||
pub(super) const WINDOWS_BUTTON_PADDING_HORIZONTAL: f32 = 12.;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows_only::*;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use warpui::elements::Empty;
|
||||
|
||||
use crate::themes::theme::WarpTheme;
|
||||
use warpui::elements::MouseStateHandle;
|
||||
use warpui::platform::FullscreenState;
|
||||
use warpui::{AppContext, Element, WindowId};
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
const BUTTON_ICON_SIZE: f32 = 22.;
|
||||
|
||||
pub fn traffic_light_data(ctx: &AppContext, window_id: WindowId) -> Option<TrafficLightData> {
|
||||
// If native window frame is on, the traffic lights are already in the frame.
|
||||
if ctx
|
||||
.windows()
|
||||
.platform_window(window_id)
|
||||
.is_some_and(|window| window.uses_native_window_decorations())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
Some(TrafficLightData {
|
||||
width: 64.,
|
||||
side: TrafficLightSide::Left,
|
||||
scales_with_zoom: false,
|
||||
})
|
||||
} else if cfg!(target_os = "linux") && !ctx.windows().is_tiling_window_manager() {
|
||||
Some(TrafficLightData {
|
||||
width: 116.,
|
||||
side: TrafficLightSide::Right,
|
||||
scales_with_zoom: true,
|
||||
})
|
||||
} else if cfg!(target_os = "windows") {
|
||||
Some(TrafficLightData {
|
||||
width: 136.,
|
||||
side: TrafficLightSide::Right,
|
||||
scales_with_zoom: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Are they in the upper-right or upper-left corner?
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TrafficLightSide {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Mouse state handles that the containing View must manage.
|
||||
#[derive(Default)]
|
||||
#[cfg_attr(any(target_family = "wasm", target_os = "macos"), allow(dead_code))]
|
||||
pub struct TrafficLightMouseStates {
|
||||
pub minimize_window_button: MouseStateHandle,
|
||||
pub maximize_window_button: MouseStateHandle,
|
||||
pub close_window_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl TrafficLightMouseStates {
|
||||
/// True if any of the traffic light buttons are hovered.
|
||||
pub fn are_traffic_lights_hovered(&self) -> bool {
|
||||
[
|
||||
&self.minimize_window_button,
|
||||
&self.maximize_window_button,
|
||||
&self.close_window_button,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|state| state.lock().is_ok_and(|state| state.is_hovered()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Data the Warp app needs to avoid rendering anything below the traffic lights.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrafficLightData {
|
||||
width: f32,
|
||||
pub side: TrafficLightSide,
|
||||
/// Whether the traffic lights can scale with the app's zoom level.
|
||||
///
|
||||
/// If `false` that means the traffic light buttons are of fixed size as determined by the OS
|
||||
/// and we cannot scale them as the user configures the zoom level.
|
||||
scales_with_zoom: bool,
|
||||
}
|
||||
|
||||
impl TrafficLightData {
|
||||
/// Horizontal space needed for the traffic light buttons.
|
||||
///
|
||||
/// Normally, we don't need to manually adjust any sizes based on zoom level as it is handled
|
||||
/// by warpui. However, native traffic light buttons (e.g. on macOS) don't scale with zoom, so
|
||||
/// we need to divide by the zoom factor to keep the padding constant.
|
||||
pub fn width(&self, zoom_factor: f32) -> f32 {
|
||||
if self.scales_with_zoom {
|
||||
self.width
|
||||
} else {
|
||||
self.width / zoom_factor
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn render(
|
||||
&self,
|
||||
fullscreen_state: FullscreenState,
|
||||
mouse_states: &TrafficLightMouseStates,
|
||||
theme: &WarpTheme,
|
||||
_app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
let fg_color = theme.foreground().into_solid();
|
||||
let maximize_button_icon =
|
||||
Self::render_linux_maximize_button_icon(fg_color, fullscreen_state);
|
||||
|
||||
ConstrainedBox::new(
|
||||
Align::new(
|
||||
Flex::row()
|
||||
.with_children([
|
||||
Container::new(
|
||||
Self::render_button(
|
||||
Arc::clone(&mouse_states.minimize_window_button),
|
||||
ConstrainedBox::new(
|
||||
Rect::new().with_background_color(fg_color).finish(),
|
||||
)
|
||||
.with_height(2.)
|
||||
.with_width(8.)
|
||||
.finish(),
|
||||
theme,
|
||||
)
|
||||
.on_click(|evt, _, _| {
|
||||
evt.dispatch_action("root_view:minimize_window", ());
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(16.)
|
||||
.finish(),
|
||||
Self::render_button(
|
||||
Arc::clone(&mouse_states.maximize_window_button),
|
||||
maximize_button_icon,
|
||||
theme,
|
||||
)
|
||||
.on_click(move |evt, _, _| {
|
||||
if fullscreen_state == FullscreenState::Fullscreen {
|
||||
evt.dispatch_action("root_view:toggle_fullscreen", ());
|
||||
} else {
|
||||
evt.dispatch_action("root_view:toggle_maximize_window", ());
|
||||
}
|
||||
})
|
||||
.finish(),
|
||||
Container::new(
|
||||
Self::render_button(
|
||||
Arc::clone(&mouse_states.close_window_button),
|
||||
ConstrainedBox::new(
|
||||
Icon::new("bundled/svg/linux/decorations/close.svg", fg_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(8.)
|
||||
.with_width(8.)
|
||||
.finish(),
|
||||
theme,
|
||||
)
|
||||
.on_click(|evt, _, _| {
|
||||
evt.dispatch_action("root_view:close_window", ());
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(16.)
|
||||
.with_margin_right(12.)
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(TOTAL_TAB_BAR_HEIGHT)
|
||||
.with_width(self.width)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn render_linux_maximize_button_icon(
|
||||
fg_color: ColorU,
|
||||
fullscreen_state: FullscreenState,
|
||||
) -> Box<dyn Element> {
|
||||
let mut maximize_button_icon = ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_border(Border::all(2.).with_border_color(fg_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(6.)
|
||||
.with_height(6.)
|
||||
.finish();
|
||||
|
||||
// If the window is already maximized, the icon looks a bit different.
|
||||
if fullscreen_state != FullscreenState::Normal {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
maximize_button_icon,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::BottomLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_border(
|
||||
Border::new(1.)
|
||||
.with_sides(true, false, false, true)
|
||||
.with_border_color(fg_color),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(1.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(6.)
|
||||
.with_height(6.)
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
maximize_button_icon = ConstrainedBox::new(stack.finish())
|
||||
.with_width(8.)
|
||||
.with_height(8.)
|
||||
.finish();
|
||||
}
|
||||
|
||||
maximize_button_icon
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn render_button(
|
||||
mouse_state: MouseStateHandle,
|
||||
child: Box<dyn Element>,
|
||||
theme: &WarpTheme,
|
||||
) -> Hoverable {
|
||||
Hoverable::new(mouse_state, |state| {
|
||||
let background_color = if state.is_hovered() {
|
||||
theme.surface_3()
|
||||
} else {
|
||||
theme.surface_2()
|
||||
};
|
||||
Container::new(
|
||||
ConstrainedBox::new(Align::new(child).finish())
|
||||
.with_width(BUTTON_ICON_SIZE)
|
||||
.with_height(BUTTON_ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_background(background_color)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.finish()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn render(
|
||||
&self,
|
||||
fullscreen_state: FullscreenState,
|
||||
mouse_states: &TrafficLightMouseStates,
|
||||
theme: &WarpTheme,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
self.render_tab_row(fullscreen_state, mouse_states, theme, app)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn render_windows_minimize_button_icon(fg_color: ColorU) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(Rect::new().with_background_color(fg_color).finish())
|
||||
.with_height(1.)
|
||||
.with_width(12.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn render_windows_maximize_button_icon(
|
||||
fg_color: ColorU,
|
||||
fullscreen_state: FullscreenState,
|
||||
) -> Box<dyn Element> {
|
||||
let mut maximize_button_icon = ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_border(Border::all(1.).with_border_color(fg_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(10.)
|
||||
.with_height(10.)
|
||||
.finish();
|
||||
|
||||
// If the window is already maximized, the icon looks a bit different.
|
||||
if fullscreen_state != FullscreenState::Normal {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
maximize_button_icon,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::BottomLeft,
|
||||
ChildAnchor::BottomLeft,
|
||||
),
|
||||
);
|
||||
stack.add_positioned_child(
|
||||
ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_border(
|
||||
Border::new(1.)
|
||||
.with_sides(true, false, false, true)
|
||||
.with_border_color(fg_color),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(10.)
|
||||
.with_height(10.)
|
||||
.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
maximize_button_icon = ConstrainedBox::new(stack.finish())
|
||||
.with_width(12.)
|
||||
.with_height(12.)
|
||||
.finish();
|
||||
}
|
||||
|
||||
maximize_button_icon
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn render_windows_close_button(fg_color: ColorU, mouse_state: MouseStateHandle) -> Hoverable {
|
||||
Hoverable::new(mouse_state, |state| {
|
||||
let (background_color, icon_color) = if state.is_hovered() {
|
||||
(WINDOWS_BRIGHT_RED, ColorU::white())
|
||||
} else {
|
||||
(ColorU::transparent_black(), fg_color)
|
||||
};
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Align::new(Self::render_windows_close_button_icon(icon_color)).finish(),
|
||||
)
|
||||
.with_width(BUTTON_ICON_SIZE)
|
||||
.with_height(BUTTON_ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(WINDOWS_BUTTON_PADDING_VERTICAL)
|
||||
.with_horizontal_padding(WINDOWS_BUTTON_PADDING_HORIZONTAL)
|
||||
.with_background_color(background_color)
|
||||
.finish()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn render_windows_close_button_icon(icon_color: ColorU) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(
|
||||
IconComponent::X
|
||||
.to_warpui_icon(theme::Fill::Solid(icon_color))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn render_button(
|
||||
mouse_state: MouseStateHandle,
|
||||
child: Box<dyn Element>,
|
||||
hover_color: ColorU,
|
||||
) -> Hoverable {
|
||||
Hoverable::new(mouse_state, |state| {
|
||||
let background_color = if state.is_hovered() {
|
||||
hover_color
|
||||
} else {
|
||||
ColorU::transparent_black()
|
||||
};
|
||||
Container::new(
|
||||
ConstrainedBox::new(Align::new(child).finish())
|
||||
.with_width(BUTTON_ICON_SIZE)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(WINDOWS_BUTTON_PADDING_VERTICAL)
|
||||
.with_horizontal_padding(WINDOWS_BUTTON_PADDING_HORIZONTAL)
|
||||
.with_background_color(background_color)
|
||||
.finish()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
|
||||
pub fn render(
|
||||
&self,
|
||||
_fullscreen_state: FullscreenState,
|
||||
_mouse_states: &TrafficLightMouseStates,
|
||||
_theme: &WarpTheme,
|
||||
_app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub(super) mod renderer;
|
||||
mod renderer_state;
|
||||
|
||||
pub use renderer_state::RendererState;
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Module containing helper functions to render the windows traffic lights.
|
||||
|
||||
use crate::util::traffic_lights::windows::RendererState;
|
||||
use crate::util::traffic_lights::windows_only::WINDOWS_BRIGHT_RED;
|
||||
use crate::util::traffic_lights::{TrafficLightData, TrafficLightMouseStates};
|
||||
use crate::workspace::TOTAL_TAB_BAR_HEIGHT;
|
||||
use pathfinder_color::ColorU;
|
||||
use std::sync::Arc;
|
||||
use warp_core::ui::theme::{Fill, WarpTheme};
|
||||
use warpui::elements::{
|
||||
Align, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::FamilyId;
|
||||
use warpui::platform::FullscreenState;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
/// Possible window traffic light icons.
|
||||
#[derive(Copy, Clone)]
|
||||
pub(super) enum WindowsTrafficLightIcon {
|
||||
Close,
|
||||
Minimize,
|
||||
Maximize,
|
||||
Restore,
|
||||
}
|
||||
|
||||
/// The golden ratio. Windows uses this ratio as the line height--using it ensures that each symbol
|
||||
/// icon is perfectly centered within its bounding box.
|
||||
const GOLDEN_RATIO: f32 = 1.618_034;
|
||||
|
||||
/// The width of each icon. Though not well documented, this matches the exact width of the window
|
||||
/// controls when rendered natively by the OS.
|
||||
const ICON_WIDTH: f32 = 46.;
|
||||
|
||||
/// The font size each icon should be rendered at when using a symbol font.
|
||||
const ICON_FONT_SIZE: f32 = 10.;
|
||||
|
||||
impl WindowsTrafficLightIcon {
|
||||
/// Returns the unicode point of each traffic light icon when using a native windows symbol font.
|
||||
/// See https://learn.microsoft.com/en-us/windows/apps/design/style/segoe-fluent-icons-font#pua-e700-e900
|
||||
/// for reference.
|
||||
fn unicode_code_point(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Minimize => "\u{e921}",
|
||||
Self::Restore => "\u{e923}",
|
||||
Self::Maximize => "\u{e922}",
|
||||
Self::Close => "\u{e8bb}",
|
||||
}
|
||||
}
|
||||
|
||||
fn background_hover_color(&self, theme: &WarpTheme) -> Fill {
|
||||
match self {
|
||||
Self::Close => WINDOWS_BRIGHT_RED.into(),
|
||||
Self::Minimize | Self::Maximize | Self::Restore => theme.surface_3(),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_hover_color(&self, theme: &WarpTheme) -> ColorU {
|
||||
match self {
|
||||
Self::Close => ColorU::white(),
|
||||
Self::Minimize | Self::Maximize | Self::Restore => self.icon_color(theme),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_color(&self, theme: &WarpTheme) -> ColorU {
|
||||
theme.foreground().into_solid()
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
theme: &WarpTheme,
|
||||
icon_font_family: FamilyId,
|
||||
action_name: &'static str,
|
||||
) -> Box<dyn Element> {
|
||||
let hoverable = Hoverable::new(mouse_state_handle, |state| {
|
||||
let icon_color = if state.is_hovered() {
|
||||
self.icon_hover_color(theme)
|
||||
} else {
|
||||
self.icon_color(theme)
|
||||
};
|
||||
|
||||
let icon = Text::new(self.unicode_code_point(), icon_font_family, ICON_FONT_SIZE)
|
||||
.with_color(icon_color)
|
||||
.with_line_height_ratio(GOLDEN_RATIO)
|
||||
.finish();
|
||||
let icon = Align::new(icon).finish();
|
||||
if state.is_hovered() {
|
||||
Container::new(icon)
|
||||
.with_background(self.background_hover_color(theme))
|
||||
.finish()
|
||||
} else {
|
||||
icon
|
||||
}
|
||||
})
|
||||
.on_click(move |evt, _, _| {
|
||||
evt.dispatch_action(action_name, ());
|
||||
})
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(hoverable)
|
||||
.with_width(ICON_WIDTH)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tab_row_with_glyph_icons(
|
||||
fullscreen_state: FullscreenState,
|
||||
mouse_states: &TrafficLightMouseStates,
|
||||
theme: &WarpTheme,
|
||||
icon_font_family: FamilyId,
|
||||
) -> Box<dyn Element> {
|
||||
let flex = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_children([
|
||||
WindowsTrafficLightIcon::Minimize.render(
|
||||
mouse_states.minimize_window_button.clone(),
|
||||
theme,
|
||||
icon_font_family,
|
||||
"root_view:minimize_window",
|
||||
),
|
||||
{
|
||||
let icon = if fullscreen_state == FullscreenState::Normal {
|
||||
WindowsTrafficLightIcon::Maximize
|
||||
} else {
|
||||
WindowsTrafficLightIcon::Restore
|
||||
};
|
||||
let action_name = if fullscreen_state == FullscreenState::Fullscreen {
|
||||
"root_view:toggle_fullscreen"
|
||||
} else {
|
||||
"root_view:toggle_maximize_window"
|
||||
};
|
||||
icon.render(
|
||||
mouse_states.maximize_window_button.clone(),
|
||||
theme,
|
||||
icon_font_family,
|
||||
action_name,
|
||||
)
|
||||
},
|
||||
WindowsTrafficLightIcon::Close.render(
|
||||
mouse_states.close_window_button.clone(),
|
||||
theme,
|
||||
icon_font_family,
|
||||
"root_view:close_window",
|
||||
),
|
||||
])
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(flex)
|
||||
.with_height(TOTAL_TAB_BAR_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
|
||||
impl TrafficLightData {
|
||||
pub fn render_tab_row(
|
||||
&self,
|
||||
fullscreen_state: FullscreenState,
|
||||
mouse_states: &TrafficLightMouseStates,
|
||||
theme: &WarpTheme,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
match RendererState::handle(app).as_ref(app).icon_font_family() {
|
||||
Some(icon_font_family) => render_tab_row_with_glyph_icons(
|
||||
fullscreen_state,
|
||||
mouse_states,
|
||||
theme,
|
||||
icon_font_family,
|
||||
),
|
||||
None => {
|
||||
// If we were unable to fetch the icon font family, render the tab bar using SVG
|
||||
// icons instead.
|
||||
log::warn!(
|
||||
"Unable to fetch a windows font to render the tab bar, using svgs instead."
|
||||
);
|
||||
self.render_tab_row_with_svg_icons(fullscreen_state, mouse_states, theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the windows traffic lights with SVG icons. This is a fallback approach if the system
|
||||
/// does not contain the symbol fonts needed to render the traffic lights.
|
||||
fn render_tab_row_with_svg_icons(
|
||||
&self,
|
||||
fullscreen_state: FullscreenState,
|
||||
mouse_states: &TrafficLightMouseStates,
|
||||
theme: &WarpTheme,
|
||||
) -> Box<dyn Element> {
|
||||
let fg_color = theme.foreground().into_solid();
|
||||
ConstrainedBox::new(
|
||||
Align::new(
|
||||
Flex::row()
|
||||
.with_children([
|
||||
Container::new(
|
||||
Self::render_button(
|
||||
Arc::clone(&mouse_states.minimize_window_button),
|
||||
Self::render_windows_minimize_button_icon(fg_color),
|
||||
theme.surface_3().into(),
|
||||
)
|
||||
.on_click(|evt, _, _| {
|
||||
evt.dispatch_action("root_view:minimize_window", ());
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
Self::render_button(
|
||||
Arc::clone(&mouse_states.maximize_window_button),
|
||||
Self::render_windows_maximize_button_icon(fg_color, fullscreen_state),
|
||||
theme.surface_3().into(),
|
||||
)
|
||||
.on_click(move |evt, _, _| {
|
||||
if fullscreen_state == FullscreenState::Fullscreen {
|
||||
evt.dispatch_action("root_view:toggle_fullscreen", ());
|
||||
} else {
|
||||
evt.dispatch_action("root_view:toggle_maximize_window", ());
|
||||
}
|
||||
})
|
||||
.finish(),
|
||||
Container::new(
|
||||
Self::render_windows_close_button(
|
||||
fg_color,
|
||||
mouse_states.close_window_button.clone(),
|
||||
)
|
||||
.on_click(|evt, _, _| {
|
||||
evt.dispatch_action("root_view:close_window", ());
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
])
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(TOTAL_TAB_BAR_HEIGHT)
|
||||
.with_width(self.width)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Module containing the definition of [`RendererState`].
|
||||
|
||||
use warpui::fonts::FamilyId;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// Helper singleton model that stores the icon font used to render native window controls on
|
||||
/// Windows. Using a symbol font (as opposed to SVGs) produces windows controls that are better
|
||||
/// aliased and more closely match the controls in other apps.
|
||||
pub struct RendererState {
|
||||
icon_font_family: Option<FamilyId>,
|
||||
}
|
||||
|
||||
impl RendererState {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
use windows::Wdk::System::SystemServices::RtlGetVersion;
|
||||
|
||||
let mut version = unsafe { std::mem::zeroed() };
|
||||
let status = unsafe { RtlGetVersion(&mut version) };
|
||||
|
||||
// "Segoue Fluent Icons" is the recommended symbol font on Windows 11 and is bundled with the
|
||||
// OS. On prior versions, the recommend symbol font is "Segoe MDL2 Assets".
|
||||
// See https://learn.microsoft.com/en-us/windows/apps/design/style/segoe-fluent-icons-font.
|
||||
let symbol_font = if status.is_ok() && version.dwBuildNumber >= 22000 {
|
||||
Self::load_symbol_font("Segoe Fluent Icons", ctx)
|
||||
.or_else(|| Self::load_symbol_font("Segoe MDL2 Assets", ctx))
|
||||
} else {
|
||||
Self::load_symbol_font("Segoe MDL2 Assets", ctx)
|
||||
};
|
||||
|
||||
Self {
|
||||
icon_font_family: symbol_font,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_symbol_font(symbol_font_to_load: &str, ctx: &mut AppContext) -> Option<FamilyId> {
|
||||
warpui::fonts::Cache::handle(ctx).update(ctx, |font_cache, _| {
|
||||
match font_cache.get_or_load_system_font(symbol_font_to_load) {
|
||||
Ok(family) => Some(family),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to load windows symbol font due to error {err:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the icon font family to use to render the window controls, or `None` if the font was
|
||||
/// not on the user's system for any reason.
|
||||
pub(super) fn icon_font_family(&self) -> Option<FamilyId> {
|
||||
self.icon_font_family
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RendererState {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for RendererState {}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// Truncate text from the end with ellipsis if it exceeds max_length.
|
||||
/// Properly handles UTF-8 character boundaries to avoid panics.
|
||||
pub fn truncate_from_end(text: &str, max_length: usize) -> String {
|
||||
let char_count = text.chars().count();
|
||||
if char_count <= max_length {
|
||||
text.to_string()
|
||||
} else {
|
||||
let chars_to_take = max_length.saturating_sub(1);
|
||||
let truncated: String = text.chars().take(chars_to_take).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate text from the beginning with ellipsis if it exceeds max_length.
|
||||
/// Properly handles UTF-8 character boundaries to avoid panics.
|
||||
pub fn truncate_from_beginning(text: &str, max_length: usize) -> String {
|
||||
let char_count = text.chars().count();
|
||||
if char_count <= max_length {
|
||||
text.to_string()
|
||||
} else {
|
||||
let chars_to_take = max_length.saturating_sub(1);
|
||||
let truncated: String = text.chars().skip(char_count - chars_to_take).collect();
|
||||
format!("…{truncated}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// Detects if we're running in a Windows Parallels VM.
|
||||
#[cfg(windows)]
|
||||
pub fn is_running_in_windows_parallels_vm() -> bool {
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
||||
|
||||
if let Ok(system_key) = hklm.open_subkey(r"HARDWARE\DESCRIPTION\System\BIOS") {
|
||||
if let Ok(bios_version) = system_key.get_value::<String, _>("SystemManufacturer") {
|
||||
if bios_version.to_lowercase().contains("parallels") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn is_running_in_windows_parallels_vm() -> bool {
|
||||
// On non-Windows platforms, we don't need this check
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use std::{env, path};
|
||||
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::system::SystemInfo;
|
||||
use crate::util::path::{file_exists_and_is_executable, resolve_executable};
|
||||
|
||||
const KASPERSKY_PROCESS_NAME: &str = "avp";
|
||||
const PWSH_EXE: &str = "pwsh.exe";
|
||||
const POWERSHELL_EXE: &str = "powershell.exe";
|
||||
const WSL_EXE: &str = "wsl.exe";
|
||||
|
||||
static POWERSHELL_7_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(find_powershell_7_path);
|
||||
static POWERSHELL_5_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(find_powershell_5_path);
|
||||
static WSL_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(find_wsl_path);
|
||||
|
||||
/// Returns the location which Warp was installed to.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn install_dir() -> Result<path::PathBuf> {
|
||||
let current_exe = env::current_exe()?;
|
||||
current_exe
|
||||
.parent()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or(anyhow!("Unable to get install dir"))
|
||||
}
|
||||
|
||||
/// Returns the path to the PowerShell 7 executable on the user's machine, if we
|
||||
/// were able to find one.
|
||||
pub fn powershell_7_path() -> Option<&'static PathBuf> {
|
||||
POWERSHELL_7_PATH.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the path to the PowerShell 5 executable on the user's machine, if we
|
||||
/// were able to find one.
|
||||
pub fn powershell_5_path() -> Option<&'static PathBuf> {
|
||||
POWERSHELL_5_PATH.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the path to the a PowerShell 7 or PowerShell 5 executable on the
|
||||
/// user's machine, if we were able to find one. Prefers PowerShell 7.
|
||||
pub fn any_powershell_path() -> Option<&'static PathBuf> {
|
||||
if let Some(path) = POWERSHELL_7_PATH.as_ref() {
|
||||
return Some(path);
|
||||
}
|
||||
POWERSHELL_5_PATH.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the path to the WSL executable on the user's machine, if we were able
|
||||
/// to find one.
|
||||
pub fn wsl_path() -> Option<&'static PathBuf> {
|
||||
WSL_PATH.as_ref()
|
||||
}
|
||||
|
||||
/// Searches the user's system for a PowerShell 7 executable and returns the
|
||||
/// full path to the executable.
|
||||
fn find_powershell_7_path() -> Option<PathBuf> {
|
||||
for install_path in powershell_7_install_paths() {
|
||||
let exe_path = install_path.join(PWSH_EXE);
|
||||
if file_exists_and_is_executable(&exe_path) {
|
||||
return Some(exe_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the executable is in the PATH.
|
||||
let resolved_executable = resolve_executable(PWSH_EXE).map(|path| path.into_owned());
|
||||
if resolved_executable.is_some() {
|
||||
return resolved_executable;
|
||||
}
|
||||
|
||||
log::warn!("Failed to find pwsh.exe on system");
|
||||
None
|
||||
}
|
||||
|
||||
/// Searches the user's system for a PowerShell 5 executable and returns the
|
||||
/// full path to the executable.
|
||||
fn find_powershell_5_path() -> Option<PathBuf> {
|
||||
// Check the default install location.
|
||||
let exe_path = powershell_5_install_path().join(POWERSHELL_EXE);
|
||||
if file_exists_and_is_executable(&exe_path) {
|
||||
return Some(exe_path);
|
||||
}
|
||||
|
||||
// Check if the executable is in the PATH.
|
||||
let resolved_executable = resolve_executable(POWERSHELL_EXE).map(|path| path.into_owned());
|
||||
if resolved_executable.is_some() {
|
||||
return resolved_executable;
|
||||
}
|
||||
|
||||
log::warn!("Failed to find powershell.exe on system");
|
||||
None
|
||||
}
|
||||
|
||||
fn find_wsl_path() -> Option<PathBuf> {
|
||||
// Check the default install location.
|
||||
let system_root = std::env::var("SYSTEMROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("C:").join("Windows"));
|
||||
let wsl_path = system_root.join("System32").join(WSL_EXE);
|
||||
if file_exists_and_is_executable(&wsl_path) {
|
||||
return Some(wsl_path);
|
||||
}
|
||||
|
||||
// Check if the executable is in the PATH.
|
||||
let resolved_executable = resolve_executable(WSL_EXE).map(|path| path.into_owned());
|
||||
if resolved_executable.is_some() {
|
||||
return resolved_executable;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the default location where the PowerShell 5 executable
|
||||
/// is usually installed.
|
||||
pub fn powershell_5_install_path() -> PathBuf {
|
||||
let system_root = std::env::var("SYSTEMROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("C:").join("Windows"));
|
||||
system_root
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
}
|
||||
|
||||
/// Returns the default locations where the PowerShell 7 executable
|
||||
/// is usually installed.
|
||||
///
|
||||
/// The locations to search have been adapted from Windows Terminal:
|
||||
/// https://github.com/microsoft/terminal/blob/e1be2f4c73b8a8d55e07a9499a72d7b943ac3fe7/src/cascadia/TerminalSettingsModel/PowershellCoreProfileGenerator.cpp#L264-L286
|
||||
///
|
||||
/// Adapted under the MIT License, Copyright (c) Microsoft Corporation. See app/assets/windows/LICENSE-WINDOWS-TERMINAL.
|
||||
pub fn powershell_7_install_paths() -> impl Iterator<Item = PathBuf> {
|
||||
powershell_7_program_files_paths()
|
||||
.chain(dotnet_tools_path())
|
||||
.chain(scoop_shims_path())
|
||||
.chain(microsoft_store_app_path())
|
||||
}
|
||||
|
||||
fn powershell_7_program_files_paths() -> impl Iterator<Item = PathBuf> {
|
||||
let program_files = env::var("PROGRAMFILES")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("C:").join("Program Files"));
|
||||
let program_files_paths = find_powershell_7_program_files_paths(program_files);
|
||||
|
||||
// On 64 bit systems, check the "Program Files (x86)" directory.
|
||||
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
|
||||
let program_files_paths = {
|
||||
let program_files_x86 = env::var("PROGRAMFILES(X86)")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("C:").join("Program Files (x86)"));
|
||||
program_files_paths.chain(find_powershell_7_program_files_paths(program_files_x86))
|
||||
};
|
||||
|
||||
// On ARM64 systems, check the "Program Files (Arm)" directory.
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
let program_files_paths = {
|
||||
let program_files_arm = env::var("PROGRAMFILES(ARM)")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new("C:").join("Program Files (Arm)"));
|
||||
program_files_paths.chain(find_powershell_7_program_files_paths(program_files_arm))
|
||||
};
|
||||
|
||||
program_files_paths
|
||||
}
|
||||
|
||||
/// Given a Program Files directory, return the possible install locations for
|
||||
/// the PowerShell 7 executable within that directory.
|
||||
fn find_powershell_7_program_files_paths(program_files: PathBuf) -> impl Iterator<Item = PathBuf> {
|
||||
// We could be more robust in our search by iterating over all subdirectories
|
||||
// of `{directory}/PowerShell`, but this simplifies the logic and it's highly
|
||||
// unlikely that users would have versions other than the hardcoded ones here.
|
||||
["7", "7-preview"]
|
||||
.into_iter()
|
||||
.map(move |version| program_files.join("PowerShell").join(version))
|
||||
}
|
||||
|
||||
fn dotnet_tools_path() -> Option<PathBuf> {
|
||||
env::var("USERPROFILE")
|
||||
.map(PathBuf::from)
|
||||
.map(|user_profile| user_profile.join(".dotnet").join("tools"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn scoop_shims_path() -> Option<PathBuf> {
|
||||
env::var("USERPROFILE")
|
||||
.map(PathBuf::from)
|
||||
.map(|user_profile| user_profile.join("scoop").join("shims"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn microsoft_store_app_path() -> Option<PathBuf> {
|
||||
let windows_base_dirs = directories::BaseDirs::new()?;
|
||||
let mut microsoft_store_app_path = PathBuf::from(windows_base_dirs.data_local_dir());
|
||||
microsoft_store_app_path.push("Microsoft");
|
||||
microsoft_store_app_path.push("WindowsApps");
|
||||
Some(microsoft_store_app_path)
|
||||
}
|
||||
|
||||
/// Determines if Kaspersky is currently running by checking if there is a
|
||||
/// process with the name "avp" running.
|
||||
pub fn is_kaspersky_running(ctx: &mut AppContext) -> bool {
|
||||
SystemInfo::handle(ctx).update(ctx, |system_info, _| {
|
||||
system_info.refresh_all_processes();
|
||||
system_info
|
||||
.processes_by_name(KASPERSKY_PROCESS_NAME)
|
||||
.next()
|
||||
.is_some()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user