Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+953
View File
@@ -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;
+74
View File
@@ -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()
);
});
});
}
+29
View File
@@ -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,
}
}
+2
View File
@@ -0,0 +1,2 @@
pub use warp_core::ui::color::contrast::*;
pub use warp_core::ui::color::*;
+54
View File
@@ -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();
}
}
}
+148
View File
@@ -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)
}
}
}
+572
View File
@@ -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(())
},
);
}
+370
View File
@@ -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
}
+326
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -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}"
);
}
+113
View File
@@ -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;
+137
View File
@@ -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
);
}
+722
View File
@@ -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;
+75
View File
@@ -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"
]
);
}
+18
View File
@@ -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()
}
+221
View File
@@ -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;
+112
View File
@@ -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);
}
+347
View File
@@ -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")));
}
}
+52
View File
@@ -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;
+49
View File
@@ -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());
}
+95
View File
@@ -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()
}
}
+52
View File
@@ -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());
})
}
+153
View File
@@ -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;
+95
View File
@@ -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()
);
}
+266
View File
@@ -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
}
+455
View File
@@ -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 {}
+25
View File
@@ -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}")
}
}
+24
View File
@@ -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
}
+211
View File
@@ -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()
})
}