Compare commits
19 Commits
nixos-test
...
git-clone
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc3b3a04f | ||
|
|
422dc4f307 | ||
|
|
b1aa0e2efd | ||
|
|
3dbfee1c47 | ||
|
|
0c9992c5e9 | ||
|
|
cec46079fe | ||
|
|
f9b69aeff0 | ||
|
|
f00cb371f4 | ||
|
|
25e1e2ecdd | ||
|
|
f2d29f4790 | ||
|
|
623e13761b | ||
|
|
302a4bbdd0 | ||
|
|
c4f8f2fbf4 | ||
|
|
52c7447106 | ||
|
|
4930d3aa80 | ||
|
|
5d633a3968 | ||
|
|
6967ea41e5 | ||
|
|
5068581b39 | ||
|
|
f3bd6b88db |
2
.github/workflows/autofix_pr.yml
vendored
2
.github/workflows/autofix_pr.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
steps:
|
||||
- id: get-app-token
|
||||
name: autofix_pr::commit_changes::authenticate_as_zippy
|
||||
name: steps::authenticate_as_zippy
|
||||
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
|
||||
with:
|
||||
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
|
||||
|
||||
2
.github/workflows/cherry_pick.yml
vendored
2
.github/workflows/cherry_pick.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
clean: false
|
||||
- id: get-app-token
|
||||
name: cherry_pick::run_cherry_pick::authenticate_as_zippy
|
||||
name: steps::authenticate_as_zippy
|
||||
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
|
||||
with:
|
||||
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -478,11 +478,17 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
steps:
|
||||
- id: get-app-token
|
||||
name: steps::authenticate_as_zippy
|
||||
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
|
||||
with:
|
||||
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
|
||||
private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
|
||||
- name: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
|
||||
run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
|
||||
notify_on_failure:
|
||||
needs:
|
||||
- upload_release_assets
|
||||
|
||||
@@ -1705,7 +1705,12 @@
|
||||
// }
|
||||
//
|
||||
"file_types": {
|
||||
"JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"],
|
||||
"JSONC": [
|
||||
"**/.zed/*.json",
|
||||
"**/.vscode/**/*.json",
|
||||
"**/{zed,Zed}/{settings,keymap,tasks,debug}.json",
|
||||
"tsconfig*.json",
|
||||
],
|
||||
"Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"],
|
||||
"Shell Script": [".env.*"],
|
||||
},
|
||||
|
||||
@@ -73,11 +73,7 @@ pub use multi_buffer::{
|
||||
pub use split::SplittableEditor;
|
||||
pub use text::Bias;
|
||||
|
||||
use ::git::{
|
||||
Restore,
|
||||
blame::{BlameEntry, ParsedCommitMessage},
|
||||
status::FileStatus,
|
||||
};
|
||||
use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
|
||||
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use blink_manager::BlinkManager;
|
||||
|
||||
@@ -37,11 +37,7 @@ use crate::{
|
||||
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
|
||||
use collections::{BTreeMap, HashMap};
|
||||
use file_icons::FileIcons;
|
||||
use git::{
|
||||
Oid,
|
||||
blame::{BlameEntry, ParsedCommitMessage},
|
||||
status::FileStatus,
|
||||
};
|
||||
use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
|
||||
use gpui::{
|
||||
Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
|
||||
Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
|
||||
|
||||
@@ -3,9 +3,9 @@ use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
|
||||
use git::{
|
||||
GitHostingProviderRegistry, GitRemote, Oid,
|
||||
blame::{Blame, BlameEntry, ParsedCommitMessage},
|
||||
parse_git_remote_url,
|
||||
GitHostingProviderRegistry, Oid,
|
||||
blame::{Blame, BlameEntry},
|
||||
commit::ParsedCommitMessage,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
|
||||
@@ -525,12 +525,7 @@ impl GitBlame {
|
||||
.git_store()
|
||||
.read(cx)
|
||||
.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
|
||||
.and_then(|(repo, _)| {
|
||||
repo.read(cx)
|
||||
.remote_upstream_url
|
||||
.clone()
|
||||
.or(repo.read(cx).remote_origin_url.clone())
|
||||
});
|
||||
.and_then(|(repo, _)| repo.read(cx).default_remote_url());
|
||||
let blame_buffer = project
|
||||
.update(cx, |project, cx| project.blame_buffer(&buffer, None, cx));
|
||||
Ok(async move {
|
||||
@@ -554,13 +549,19 @@ impl GitBlame {
|
||||
entries,
|
||||
snapshot.max_point().row,
|
||||
);
|
||||
let commit_details = parse_commit_messages(
|
||||
messages,
|
||||
remote_url,
|
||||
provider_registry.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let commit_details = messages
|
||||
.into_iter()
|
||||
.map(|(oid, message)| {
|
||||
let parsed_commit_message =
|
||||
ParsedCommitMessage::parse(
|
||||
oid.to_string(),
|
||||
message,
|
||||
remote_url.as_deref(),
|
||||
Some(provider_registry.clone()),
|
||||
);
|
||||
(oid, parsed_commit_message)
|
||||
})
|
||||
.collect();
|
||||
res.push((
|
||||
id,
|
||||
snapshot,
|
||||
@@ -680,55 +681,6 @@ fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree
|
||||
entries
|
||||
}
|
||||
|
||||
async fn parse_commit_messages(
|
||||
messages: impl IntoIterator<Item = (Oid, String)>,
|
||||
remote_url: Option<String>,
|
||||
provider_registry: Arc<GitHostingProviderRegistry>,
|
||||
) -> HashMap<Oid, ParsedCommitMessage> {
|
||||
let mut commit_details = HashMap::default();
|
||||
|
||||
let parsed_remote_url = remote_url
|
||||
.as_deref()
|
||||
.and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url));
|
||||
|
||||
for (oid, message) in messages {
|
||||
let permalink = if let Some((provider, git_remote)) = parsed_remote_url.as_ref() {
|
||||
Some(provider.build_commit_permalink(
|
||||
git_remote,
|
||||
git::BuildCommitPermalinkParams {
|
||||
sha: oid.to_string().as_str(),
|
||||
},
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let remote = parsed_remote_url
|
||||
.as_ref()
|
||||
.map(|(provider, remote)| GitRemote {
|
||||
host: provider.clone(),
|
||||
owner: remote.owner.clone().into(),
|
||||
repo: remote.repo.clone().into(),
|
||||
});
|
||||
|
||||
let pull_request = parsed_remote_url
|
||||
.as_ref()
|
||||
.and_then(|(provider, remote)| provider.extract_pull_request(remote, &message));
|
||||
|
||||
commit_details.insert(
|
||||
oid,
|
||||
ParsedCommitMessage {
|
||||
message: message.into(),
|
||||
permalink,
|
||||
remote,
|
||||
pull_request,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
commit_details
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use crate::Oid;
|
||||
use crate::commit::get_messages;
|
||||
use crate::repository::RepoPath;
|
||||
use crate::{GitRemote, Oid};
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::{HashMap, HashSet};
|
||||
use futures::AsyncWriteExt;
|
||||
use gpui::SharedString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Stdio;
|
||||
use std::{ops::Range, path::Path};
|
||||
@@ -21,14 +20,6 @@ pub struct Blame {
|
||||
pub messages: HashMap<Oid, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ParsedCommitMessage {
|
||||
pub message: SharedString,
|
||||
pub permalink: Option<url::Url>,
|
||||
pub pull_request: Option<crate::hosting_provider::PullRequest>,
|
||||
pub remote: Option<GitRemote>,
|
||||
}
|
||||
|
||||
impl Blame {
|
||||
pub async fn for_path(
|
||||
git_binary: &Path,
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
use crate::{Oid, status::StatusCode};
|
||||
use crate::{
|
||||
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url,
|
||||
status::StatusCode,
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use std::path::Path;
|
||||
use gpui::SharedString;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ParsedCommitMessage {
|
||||
pub message: SharedString,
|
||||
pub permalink: Option<url::Url>,
|
||||
pub pull_request: Option<crate::hosting_provider::PullRequest>,
|
||||
pub remote: Option<GitRemote>,
|
||||
}
|
||||
|
||||
impl ParsedCommitMessage {
|
||||
pub fn parse(
|
||||
sha: String,
|
||||
message: String,
|
||||
remote_url: Option<&str>,
|
||||
provider_registry: Option<Arc<GitHostingProviderRegistry>>,
|
||||
) -> Self {
|
||||
if let Some((hosting_provider, remote)) = provider_registry
|
||||
.and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url)))
|
||||
{
|
||||
let pull_request = hosting_provider.extract_pull_request(&remote, &message);
|
||||
Self {
|
||||
message: message.into(),
|
||||
permalink: Some(
|
||||
hosting_provider
|
||||
.build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }),
|
||||
),
|
||||
pull_request,
|
||||
remote: Some(GitRemote {
|
||||
host: hosting_provider,
|
||||
owner: remote.owner.into(),
|
||||
repo: remote.repo.into(),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
message: message.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
|
||||
if shas.is_empty() {
|
||||
|
||||
@@ -3,10 +3,7 @@ use crate::{
|
||||
commit_view::CommitView,
|
||||
};
|
||||
use editor::{BlameRenderer, Editor, hover_markdown_style};
|
||||
use git::{
|
||||
blame::{BlameEntry, ParsedCommitMessage},
|
||||
repository::CommitSummary,
|
||||
};
|
||||
use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
|
||||
use gpui::{
|
||||
ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle,
|
||||
TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
|
||||
|
||||
155
crates/git_ui/src/clone.rs
Normal file
155
crates/git_ui/src/clone.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use gpui::{App, Context, WeakEntity, Window};
|
||||
use notifications::status_toast::{StatusToast, ToastIcon};
|
||||
use std::sync::Arc;
|
||||
use ui::{Color, IconName, SharedString};
|
||||
use util::ResultExt;
|
||||
use workspace::{self, Workspace};
|
||||
|
||||
pub fn clone_and_open(
|
||||
repo_url: SharedString,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
on_success: Arc<
|
||||
dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send + Sync + 'static,
|
||||
>,
|
||||
) {
|
||||
let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select as Repository Destination".into()),
|
||||
});
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let mut paths = destination_prompt.await.ok()?.ok()??;
|
||||
let mut destination_dir = paths.pop()?;
|
||||
|
||||
let repo_name = repo_url
|
||||
.split('/')
|
||||
.next_back()
|
||||
.map(|name| name.strip_suffix(".git").unwrap_or(name))
|
||||
.unwrap_or("repository")
|
||||
.to_owned();
|
||||
|
||||
let clone_task = workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
let fs = workspace.app_state().fs.clone();
|
||||
let destination_dir = destination_dir.clone();
|
||||
let repo_url = repo_url.clone();
|
||||
cx.spawn(async move |_workspace, _cx| {
|
||||
fs.git_clone(&repo_url, destination_dir.as_path()).await
|
||||
})
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
if let Err(error) = clone_task.await {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
let toast = StatusToast::new(error.to_string(), cx, |this, _| {
|
||||
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
|
||||
.dismiss_button(true)
|
||||
});
|
||||
workspace.toggle_status_toast(toast, cx);
|
||||
})
|
||||
.log_err();
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_worktrees = workspace
|
||||
.read_with(cx, |workspace, cx| {
|
||||
workspace.project().read(cx).worktrees(cx).next().is_some()
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let prompt_answer = if has_worktrees {
|
||||
cx.update(|window, cx| {
|
||||
window.prompt(
|
||||
gpui::PromptLevel::Info,
|
||||
&format!("Git Clone: {}", repo_name),
|
||||
None,
|
||||
&["Add repo to project", "Open repo in new project"],
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok()?
|
||||
.await
|
||||
.ok()?
|
||||
} else {
|
||||
// Don't ask if project is empty
|
||||
0
|
||||
};
|
||||
|
||||
destination_dir.push(&repo_name);
|
||||
|
||||
match prompt_answer {
|
||||
0 => {
|
||||
workspace
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
let create_task = workspace.project().update(cx, |project, cx| {
|
||||
project.create_worktree(destination_dir.as_path(), true, cx)
|
||||
});
|
||||
|
||||
let workspace_weak = cx.weak_entity();
|
||||
let on_success = on_success.clone();
|
||||
cx.spawn_in(window, async move |_window, cx| {
|
||||
if create_task.await.log_err().is_some() {
|
||||
workspace_weak
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
(on_success)(workspace, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok()?;
|
||||
}
|
||||
1 => {
|
||||
workspace
|
||||
.update(cx, move |workspace, cx| {
|
||||
let app_state = workspace.app_state().clone();
|
||||
let destination_path = destination_dir.clone();
|
||||
let on_success = on_success.clone();
|
||||
|
||||
workspace::open_new(
|
||||
Default::default(),
|
||||
app_state,
|
||||
cx,
|
||||
move |workspace, window, cx| {
|
||||
cx.activate(true);
|
||||
|
||||
let create_task =
|
||||
workspace.project().update(cx, |project, cx| {
|
||||
project.create_worktree(
|
||||
destination_path.as_path(),
|
||||
true,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let workspace_weak = cx.weak_entity();
|
||||
cx.spawn_in(window, async move |_window, cx| {
|
||||
if create_task.await.log_err().is_some() {
|
||||
workspace_weak
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
(on_success)(workspace, window, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use editor::hover_markdown_style;
|
||||
use futures::Future;
|
||||
use git::blame::BlameEntry;
|
||||
use git::repository::CommitSummary;
|
||||
use git::{GitRemote, blame::ParsedCommitMessage};
|
||||
use git::{GitRemote, commit::ParsedCommitMessage};
|
||||
use gpui::{
|
||||
App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
|
||||
StatefulInteractiveElement, WeakEntity, prelude::*,
|
||||
|
||||
@@ -15,12 +15,13 @@ use askpass::AskPassDelegate;
|
||||
use cloud_llm_client::CompletionIntent;
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use editor::RewrapOptions;
|
||||
use editor::{
|
||||
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
|
||||
actions::ExpandAllDiffHunks,
|
||||
};
|
||||
use futures::StreamExt as _;
|
||||
use git::blame::ParsedCommitMessage;
|
||||
use git::commit::ParsedCommitMessage;
|
||||
use git::repository::{
|
||||
Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
|
||||
PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
|
||||
@@ -30,15 +31,14 @@ use git::stash::GitStash;
|
||||
use git::status::StageStatus;
|
||||
use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
|
||||
use git::{
|
||||
ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop,
|
||||
TrashUntrackedFiles, UnstageAll,
|
||||
ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
|
||||
StashApply, StashPop, TrashUntrackedFiles, UnstageAll,
|
||||
};
|
||||
use gpui::{
|
||||
Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
|
||||
ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
|
||||
Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point,
|
||||
size, uniform_list,
|
||||
EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
|
||||
PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
|
||||
anchored, deferred, point, size, uniform_list,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, File};
|
||||
@@ -212,8 +212,7 @@ const GIT_PANEL_KEY: &str = "GitPanel";
|
||||
|
||||
const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
|
||||
// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
|
||||
const TREE_INDENT: f32 = 12.0;
|
||||
const TREE_INDENT_GUIDE_OFFSET: f32 = 16.0;
|
||||
const TREE_INDENT: f32 = 16.0;
|
||||
|
||||
pub fn register(workspace: &mut Workspace) {
|
||||
workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
|
||||
@@ -2182,7 +2181,13 @@ impl GitPanel {
|
||||
let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
|
||||
let wrapped_message = editor.update(cx, |editor, cx| {
|
||||
editor.select_all(&Default::default(), window, cx);
|
||||
editor.rewrap(&Default::default(), window, cx);
|
||||
editor.rewrap_impl(
|
||||
RewrapOptions {
|
||||
override_language_settings: false,
|
||||
preserve_existing_whitespace: true,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
editor.text(cx)
|
||||
});
|
||||
if wrapped_message.trim().is_empty() {
|
||||
@@ -2843,93 +2848,15 @@ impl GitPanel {
|
||||
}
|
||||
|
||||
pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let path = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select as Repository Destination".into()),
|
||||
});
|
||||
|
||||
let workspace = self.workspace.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut paths = path.await.ok()?.ok()??;
|
||||
let mut path = paths.pop()?;
|
||||
let repo_name = repo.split("/").last()?.strip_suffix(".git")?.to_owned();
|
||||
|
||||
let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
|
||||
|
||||
let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
|
||||
Ok(_) => cx.update(|window, cx| {
|
||||
window.prompt(
|
||||
PromptLevel::Info,
|
||||
&format!("Git Clone: {}", repo_name),
|
||||
None,
|
||||
&["Add repo to project", "Open repo in new project"],
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
Err(e) => {
|
||||
this.update(cx, |this: &mut GitPanel, cx| {
|
||||
let toast = StatusToast::new(e.to_string(), cx, |this, _| {
|
||||
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
|
||||
.dismiss_button(true)
|
||||
});
|
||||
|
||||
this.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.toggle_status_toast(toast, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
.ok()?;
|
||||
|
||||
path.push(repo_name);
|
||||
match prompt_answer.await.ok()? {
|
||||
0 => {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace
|
||||
.project()
|
||||
.update(cx, |project, cx| {
|
||||
project.create_worktree(path.as_path(), true, cx)
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
1 => {
|
||||
workspace
|
||||
.update(cx, move |workspace, cx| {
|
||||
workspace::open_new(
|
||||
Default::default(),
|
||||
workspace.app_state().clone(),
|
||||
cx,
|
||||
move |workspace, _, cx| {
|
||||
cx.activate(true);
|
||||
workspace
|
||||
.project()
|
||||
.update(cx, |project, cx| {
|
||||
project.create_worktree(&path, true, cx)
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
crate::clone::clone_and_open(
|
||||
repo.into(),
|
||||
workspace,
|
||||
window,
|
||||
cx,
|
||||
Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -4697,7 +4624,10 @@ impl GitPanel {
|
||||
},
|
||||
)
|
||||
.with_render_fn(cx.entity(), |_, params, _, _| {
|
||||
let left_offset = px(TREE_INDENT_GUIDE_OFFSET);
|
||||
// Magic number to align the tree item is 3 here
|
||||
// because we're using 12px as the left-side padding
|
||||
// and 3 makes the alignment work with the bounding box of the icon
|
||||
let left_offset = px(TREE_INDENT + 3_f32);
|
||||
let indent_size = params.indent_size;
|
||||
let item_height = params.item_height;
|
||||
|
||||
@@ -4725,10 +4655,6 @@ impl GitPanel {
|
||||
})
|
||||
.size_full()
|
||||
.flex_grow()
|
||||
.with_sizing_behavior(ListSizingBehavior::Auto)
|
||||
.with_horizontal_sizing_behavior(
|
||||
ListHorizontalSizingBehavior::Unconstrained,
|
||||
)
|
||||
.with_width_from_item(self.max_width_item_index)
|
||||
.track_scroll(&self.scroll_handle),
|
||||
)
|
||||
@@ -4752,7 +4678,7 @@ impl GitPanel {
|
||||
}
|
||||
|
||||
fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
|
||||
Label::new(label.into()).color(color).single_line()
|
||||
Label::new(label.into()).color(color)
|
||||
}
|
||||
|
||||
fn list_item_height(&self) -> Rems {
|
||||
@@ -4774,8 +4700,8 @@ impl GitPanel {
|
||||
.h(self.list_item_height())
|
||||
.w_full()
|
||||
.items_end()
|
||||
.px(rems(0.75)) // ~12px
|
||||
.pb(rems(0.3125)) // ~ 5px
|
||||
.px_3()
|
||||
.pb_1()
|
||||
.child(
|
||||
Label::new(header.title())
|
||||
.color(Color::Muted)
|
||||
@@ -4963,113 +4889,68 @@ impl GitPanel {
|
||||
let marked_bg_alpha = 0.12;
|
||||
let state_opacity_step = 0.04;
|
||||
|
||||
let info_color = cx.theme().status().info;
|
||||
|
||||
let base_bg = match (selected, marked) {
|
||||
(true, true) => cx
|
||||
.theme()
|
||||
.status()
|
||||
.info
|
||||
.alpha(selected_bg_alpha + marked_bg_alpha),
|
||||
(true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
|
||||
(false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
|
||||
(true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha),
|
||||
(true, false) => info_color.alpha(selected_bg_alpha),
|
||||
(false, true) => info_color.alpha(marked_bg_alpha),
|
||||
_ => cx.theme().colors().ghost_element_background,
|
||||
};
|
||||
|
||||
let hover_bg = if selected {
|
||||
cx.theme()
|
||||
.status()
|
||||
.info
|
||||
.alpha(selected_bg_alpha + state_opacity_step)
|
||||
} else {
|
||||
cx.theme().colors().ghost_element_hover
|
||||
};
|
||||
|
||||
let active_bg = if selected {
|
||||
cx.theme()
|
||||
.status()
|
||||
.info
|
||||
.alpha(selected_bg_alpha + state_opacity_step * 2.0)
|
||||
} else {
|
||||
cx.theme().colors().ghost_element_active
|
||||
};
|
||||
|
||||
let mut name_row = h_flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.pl(if tree_view {
|
||||
px(depth as f32 * TREE_INDENT)
|
||||
} else {
|
||||
px(0.)
|
||||
})
|
||||
.child(git_status_icon(status));
|
||||
|
||||
name_row = if tree_view {
|
||||
name_row.child(
|
||||
self.entry_label(display_name, label_color)
|
||||
.when(status.is_deleted(), Label::strikethrough)
|
||||
.truncate(),
|
||||
let (hover_bg, active_bg) = if selected {
|
||||
(
|
||||
info_color.alpha(selected_bg_alpha + state_opacity_step),
|
||||
info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
|
||||
)
|
||||
} else {
|
||||
name_row.child(h_flex().items_center().flex_1().map(|this| {
|
||||
self.path_formatted(
|
||||
this,
|
||||
entry.parent_dir(path_style),
|
||||
path_color,
|
||||
display_name,
|
||||
label_color,
|
||||
path_style,
|
||||
git_path_style,
|
||||
status.is_deleted(),
|
||||
)
|
||||
}))
|
||||
(
|
||||
cx.theme().colors().ghost_element_hover,
|
||||
cx.theme().colors().ghost_element_active,
|
||||
)
|
||||
};
|
||||
|
||||
let name_row = h_flex()
|
||||
.min_w_0()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.child(git_status_icon(status))
|
||||
.map(|this| {
|
||||
if tree_view {
|
||||
this.pl(px(depth as f32 * TREE_INDENT)).child(
|
||||
self.entry_label(display_name, label_color)
|
||||
.when(status.is_deleted(), Label::strikethrough)
|
||||
.truncate(),
|
||||
)
|
||||
} else {
|
||||
this.child(self.path_formatted(
|
||||
entry.parent_dir(path_style),
|
||||
path_color,
|
||||
display_name,
|
||||
label_color,
|
||||
path_style,
|
||||
git_path_style,
|
||||
status.is_deleted(),
|
||||
))
|
||||
}
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(id)
|
||||
.h(self.list_item_height())
|
||||
.w_full()
|
||||
.pl_3()
|
||||
.pr_1()
|
||||
.gap_1p5()
|
||||
.border_1()
|
||||
.border_r_2()
|
||||
.when(selected && self.focus_handle.is_focused(window), |el| {
|
||||
el.border_color(cx.theme().colors().panel_focused_border)
|
||||
})
|
||||
.px(rems(0.75)) // ~12px
|
||||
.overflow_hidden()
|
||||
.flex_none()
|
||||
.gap_1p5()
|
||||
.bg(base_bg)
|
||||
.hover(|this| this.bg(hover_bg))
|
||||
.active(|this| this.bg(active_bg))
|
||||
.on_click({
|
||||
cx.listener(move |this, event: &ClickEvent, window, cx| {
|
||||
this.selected_entry = Some(ix);
|
||||
cx.notify();
|
||||
if event.modifiers().secondary() {
|
||||
this.open_file(&Default::default(), window, cx)
|
||||
} else {
|
||||
this.open_diff(&Default::default(), window, cx);
|
||||
this.focus_handle.focus(window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
move |event: &MouseDownEvent, window, cx| {
|
||||
// why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
|
||||
if event.button != MouseButton::Right {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(this) = handle.upgrade() else {
|
||||
return;
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.deploy_entry_context_menu(event.position, ix, window, cx);
|
||||
});
|
||||
cx.stop_propagation();
|
||||
},
|
||||
)
|
||||
.child(name_row.overflow_x_hidden())
|
||||
.hover(|s| s.bg(hover_bg))
|
||||
.active(|s| s.bg(active_bg))
|
||||
.child(name_row)
|
||||
.child(
|
||||
div()
|
||||
.id(checkbox_wrapper_id)
|
||||
@@ -5119,6 +5000,35 @@ impl GitPanel {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.on_click({
|
||||
cx.listener(move |this, event: &ClickEvent, window, cx| {
|
||||
this.selected_entry = Some(ix);
|
||||
cx.notify();
|
||||
if event.modifiers().secondary() {
|
||||
this.open_file(&Default::default(), window, cx)
|
||||
} else {
|
||||
this.open_diff(&Default::default(), window, cx);
|
||||
this.focus_handle.focus(window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
move |event: &MouseDownEvent, window, cx| {
|
||||
// why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
|
||||
if event.button != MouseButton::Right {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(this) = handle.upgrade() else {
|
||||
return;
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.deploy_entry_context_menu(event.position, ix, window, cx);
|
||||
});
|
||||
cx.stop_propagation();
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -5143,29 +5053,23 @@ impl GitPanel {
|
||||
let selected_bg_alpha = 0.08;
|
||||
let state_opacity_step = 0.04;
|
||||
|
||||
let base_bg = if selected {
|
||||
cx.theme().status().info.alpha(selected_bg_alpha)
|
||||
let info_color = cx.theme().status().info;
|
||||
let colors = cx.theme().colors();
|
||||
|
||||
let (base_bg, hover_bg, active_bg) = if selected {
|
||||
(
|
||||
info_color.alpha(selected_bg_alpha),
|
||||
info_color.alpha(selected_bg_alpha + state_opacity_step),
|
||||
info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
|
||||
)
|
||||
} else {
|
||||
cx.theme().colors().ghost_element_background
|
||||
(
|
||||
colors.ghost_element_background,
|
||||
colors.ghost_element_hover,
|
||||
colors.ghost_element_active,
|
||||
)
|
||||
};
|
||||
|
||||
let hover_bg = if selected {
|
||||
cx.theme()
|
||||
.status()
|
||||
.info
|
||||
.alpha(selected_bg_alpha + state_opacity_step)
|
||||
} else {
|
||||
cx.theme().colors().ghost_element_hover
|
||||
};
|
||||
|
||||
let active_bg = if selected {
|
||||
cx.theme()
|
||||
.status()
|
||||
.info
|
||||
.alpha(selected_bg_alpha + state_opacity_step * 2.0)
|
||||
} else {
|
||||
cx.theme().colors().ghost_element_active
|
||||
};
|
||||
let folder_icon = if entry.expanded {
|
||||
IconName::FolderOpen
|
||||
} else {
|
||||
@@ -5188,9 +5092,8 @@ impl GitPanel {
|
||||
};
|
||||
|
||||
let name_row = h_flex()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.pl(px(entry.depth as f32 * TREE_INDENT))
|
||||
.child(
|
||||
Icon::new(folder_icon)
|
||||
@@ -5202,28 +5105,21 @@ impl GitPanel {
|
||||
h_flex()
|
||||
.id(id)
|
||||
.h(self.list_item_height())
|
||||
.min_w_0()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.pl_3()
|
||||
.pr_1()
|
||||
.gap_1p5()
|
||||
.justify_between()
|
||||
.border_1()
|
||||
.border_r_2()
|
||||
.when(selected && self.focus_handle.is_focused(window), |el| {
|
||||
el.border_color(cx.theme().colors().panel_focused_border)
|
||||
})
|
||||
.px(rems(0.75))
|
||||
.overflow_hidden()
|
||||
.flex_none()
|
||||
.gap_1p5()
|
||||
.bg(base_bg)
|
||||
.hover(|this| this.bg(hover_bg))
|
||||
.active(|this| this.bg(active_bg))
|
||||
.on_click({
|
||||
let key = entry.key.clone();
|
||||
cx.listener(move |this, _event: &ClickEvent, window, cx| {
|
||||
this.selected_entry = Some(ix);
|
||||
this.toggle_directory(&key, window, cx);
|
||||
})
|
||||
})
|
||||
.child(name_row.overflow_x_hidden())
|
||||
.hover(|s| s.bg(hover_bg))
|
||||
.active(|s| s.bg(active_bg))
|
||||
.child(name_row)
|
||||
.child(
|
||||
div()
|
||||
.id(checkbox_wrapper_id)
|
||||
@@ -5262,12 +5158,18 @@ impl GitPanel {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.on_click({
|
||||
let key = entry.key.clone();
|
||||
cx.listener(move |this, _event: &ClickEvent, window, cx| {
|
||||
this.selected_entry = Some(ix);
|
||||
this.toggle_directory(&key, window, cx);
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn path_formatted(
|
||||
&self,
|
||||
parent: Div,
|
||||
directory: Option<String>,
|
||||
path_color: Color,
|
||||
file_name: String,
|
||||
@@ -5276,42 +5178,32 @@ impl GitPanel {
|
||||
git_path_style: GitPathStyle,
|
||||
strikethrough: bool,
|
||||
) -> Div {
|
||||
parent
|
||||
.when(git_path_style == GitPathStyle::FileNameFirst, |this| {
|
||||
this.child(
|
||||
self.entry_label(
|
||||
match directory.as_ref().is_none_or(|d| d.is_empty()) {
|
||||
true => file_name.clone(),
|
||||
false => format!("{file_name} "),
|
||||
},
|
||||
label_color,
|
||||
)
|
||||
.when(strikethrough, Label::strikethrough),
|
||||
)
|
||||
})
|
||||
.when_some(directory, |this, dir| {
|
||||
match (
|
||||
!dir.is_empty(),
|
||||
git_path_style == GitPathStyle::FileNameFirst,
|
||||
) {
|
||||
(true, true) => this.child(
|
||||
self.entry_label(dir, path_color)
|
||||
.when(strikethrough, Label::strikethrough),
|
||||
),
|
||||
(true, false) => this.child(
|
||||
self.entry_label(
|
||||
format!("{dir}{}", path_style.primary_separator()),
|
||||
path_color,
|
||||
)
|
||||
.when(strikethrough, Label::strikethrough),
|
||||
),
|
||||
_ => this,
|
||||
}
|
||||
})
|
||||
.when(git_path_style == GitPathStyle::FilePathFirst, |this| {
|
||||
this.child(
|
||||
let file_name_first = git_path_style == GitPathStyle::FileNameFirst;
|
||||
let file_path_first = git_path_style == GitPathStyle::FilePathFirst;
|
||||
|
||||
let file_name = format!("{} ", file_name);
|
||||
|
||||
h_flex()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.when(file_path_first, |this| this.flex_row_reverse())
|
||||
.child(
|
||||
div().flex_none().child(
|
||||
self.entry_label(file_name, label_color)
|
||||
.when(strikethrough, Label::strikethrough),
|
||||
),
|
||||
)
|
||||
.when_some(directory, |this, dir| {
|
||||
let path_name = if file_name_first {
|
||||
dir
|
||||
} else {
|
||||
format!("{dir}{}", path_style.primary_separator())
|
||||
};
|
||||
|
||||
this.child(
|
||||
self.entry_label(path_name, path_color)
|
||||
.truncate()
|
||||
.when(strikethrough, Label::strikethrough),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -5650,6 +5542,7 @@ impl GitPanelMessageTooltip {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let remote_url = repository.read(cx).default_remote_url();
|
||||
cx.new(|cx| {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
|
||||
@@ -5659,16 +5552,21 @@ impl GitPanelMessageTooltip {
|
||||
)
|
||||
})?;
|
||||
let details = details.await?;
|
||||
let provider_registry = cx
|
||||
.update(|_, app| GitHostingProviderRegistry::default_global(app))
|
||||
.ok();
|
||||
|
||||
let commit_details = crate::commit_tooltip::CommitDetails {
|
||||
sha: details.sha.clone(),
|
||||
author_name: details.author_name.clone(),
|
||||
author_email: details.author_email.clone(),
|
||||
commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
|
||||
message: Some(ParsedCommitMessage {
|
||||
message: details.message,
|
||||
..Default::default()
|
||||
}),
|
||||
message: Some(ParsedCommitMessage::parse(
|
||||
details.sha.to_string(),
|
||||
details.message.to_string(),
|
||||
remote_url.as_deref(),
|
||||
provider_registry,
|
||||
)),
|
||||
};
|
||||
|
||||
this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
|
||||
|
||||
@@ -10,6 +10,7 @@ use ui::{
|
||||
};
|
||||
|
||||
mod blame_ui;
|
||||
pub mod clone;
|
||||
|
||||
use git::{
|
||||
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
|
||||
|
||||
@@ -1025,13 +1025,26 @@ impl PlatformWindow for WaylandWindow {
|
||||
fn resize(&mut self, size: Size<Pixels>) {
|
||||
let state = self.borrow();
|
||||
let state_ptr = self.0.clone();
|
||||
let dp_size = size.to_device_pixels(self.scale_factor());
|
||||
|
||||
// Keep window geometry consistent with configure handling. On Wayland, window geometry is
|
||||
// surface-local: resizing should not attempt to translate the window; the compositor
|
||||
// controls placement. We also account for client-side decoration insets and tiling.
|
||||
let window_geometry = inset_by_tiling(
|
||||
Bounds {
|
||||
origin: Point::default(),
|
||||
size,
|
||||
},
|
||||
state.inset(),
|
||||
state.tiling,
|
||||
)
|
||||
.map(|v| v.0 as i32)
|
||||
.map_size(|v| if v <= 0 { 1 } else { v });
|
||||
|
||||
state.surface_state.set_geometry(
|
||||
state.bounds.origin.x.0 as i32,
|
||||
state.bounds.origin.y.0 as i32,
|
||||
dp_size.width.0,
|
||||
dp_size.height.0,
|
||||
window_geometry.origin.x,
|
||||
window_geometry.origin.y,
|
||||
window_geometry.size.width,
|
||||
window_geometry.size.height,
|
||||
);
|
||||
|
||||
state
|
||||
|
||||
@@ -182,6 +182,11 @@ impl LineWrapper {
|
||||
// Cyrillic for Russian, Ukrainian, etc.
|
||||
// https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
|
||||
matches!(c, '\u{0400}'..='\u{04FF}') ||
|
||||
|
||||
// Vietnamese (https://vietunicode.sourceforge.net/charset/)
|
||||
matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
|
||||
matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
|
||||
|
||||
// Some other known special characters that should be treated as word characters,
|
||||
// e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`,
|
||||
// `2^3`, `a~b`, `a=1`, `Self::new`, etc.
|
||||
@@ -618,7 +623,12 @@ mod tests {
|
||||
#[track_caller]
|
||||
fn assert_word(word: &str) {
|
||||
for c in word.chars() {
|
||||
assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c);
|
||||
assert!(
|
||||
LineWrapper::is_word_char(c),
|
||||
"assertion failed for '{}' (unicode 0x{:x})",
|
||||
c,
|
||||
c as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,6 +671,8 @@ mod tests {
|
||||
assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
|
||||
// Cyrillic
|
||||
assert_word("АБВГДЕЖЗИЙКЛМНОП");
|
||||
// Vietnamese (https://github.com/zed-industries/zed/issues/23245)
|
||||
assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
|
||||
|
||||
// non-word characters
|
||||
assert_not_word("你好");
|
||||
|
||||
@@ -5948,6 +5948,11 @@ impl Repository {
|
||||
self.pending_ops.edit(edits, ());
|
||||
ids
|
||||
}
|
||||
pub fn default_remote_url(&self) -> Option<String> {
|
||||
self.remote_upstream_url
|
||||
.clone()
|
||||
.or(self.remote_origin_url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_permalink_in_rust_registry_src(
|
||||
|
||||
@@ -8,8 +8,8 @@ mod terminal_slash_command;
|
||||
use assistant_slash_command::SlashCommandRegistry;
|
||||
use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
|
||||
Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
|
||||
ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
|
||||
};
|
||||
use persistence::TERMINAL_DB;
|
||||
@@ -687,10 +687,30 @@ impl TerminalView {
|
||||
|
||||
///Attempt to paste the clipboard into the terminal
|
||||
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) {
|
||||
self.terminal
|
||||
.update(cx, |terminal, _cx| terminal.paste(&clipboard_string));
|
||||
let Some(clipboard) = cx.read_from_clipboard() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if clipboard.entries().iter().any(|entry| match entry {
|
||||
ClipboardEntry::Image(image) => !image.bytes.is_empty(),
|
||||
_ => false,
|
||||
}) {
|
||||
self.forward_ctrl_v(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(text) = clipboard.text() {
|
||||
self.terminal
|
||||
.update(cx, |terminal, _cx| terminal.paste(&text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
|
||||
/// and attach images using their native workflows.
|
||||
fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
|
||||
self.terminal.update(cx, |term, _| {
|
||||
term.input(vec![0x16]);
|
||||
});
|
||||
}
|
||||
|
||||
fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
|
||||
|
||||
@@ -886,8 +886,12 @@ impl<T: Item> ItemHandle for Entity<T> {
|
||||
// Only trigger autosave if focus has truly left the item.
|
||||
// If focus is still within the item's hierarchy (e.g., moved to a context menu),
|
||||
// don't trigger autosave to avoid unwanted formatting and cursor jumps.
|
||||
// Also skip autosave if focus moved to a modal (e.g., command palette),
|
||||
// since the user is still interacting with the workspace.
|
||||
let focus_handle = item.item_focus_handle(cx);
|
||||
if !focus_handle.contains_focused(window, cx) {
|
||||
if !focus_handle.contains_focused(window, cx)
|
||||
&& !workspace.has_active_modal(window, cx)
|
||||
{
|
||||
Pane::autosave_item(&item, workspace.project.clone(), window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
BIN
crates/zed/resources/Document.icns
Normal file
BIN
crates/zed/resources/Document.icns
Normal file
Binary file not shown.
@@ -15,11 +15,13 @@ use extension::ExtensionHostProxy;
|
||||
use fs::{Fs, RealFs};
|
||||
use futures::{StreamExt, channel::oneshot, future};
|
||||
use git::GitHostingProviderRegistry;
|
||||
use git_ui::clone::clone_and_open;
|
||||
use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _};
|
||||
|
||||
use gpui_tokio::Tokio;
|
||||
use language::LanguageRegistry;
|
||||
use onboarding::{FIRST_OPEN, show_onboarding_view};
|
||||
use project_panel::ProjectPanel;
|
||||
use prompt_store::PromptBuilder;
|
||||
use remote::RemoteConnectionOptions;
|
||||
use reqwest_client::ReqwestClient;
|
||||
@@ -33,10 +35,12 @@ use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
|
||||
use session::{AppSession, Session};
|
||||
use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
env,
|
||||
io::{self, IsTerminal},
|
||||
path::{Path, PathBuf},
|
||||
process,
|
||||
rc::Rc,
|
||||
sync::{Arc, OnceLock},
|
||||
time::Instant,
|
||||
};
|
||||
@@ -893,6 +897,41 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
OpenRequestKind::GitClone { repo_url } => {
|
||||
workspace::with_active_or_new_workspace(cx, |_workspace, window, cx| {
|
||||
if window.is_window_active() {
|
||||
clone_and_open(
|
||||
repo_url,
|
||||
cx.weak_entity(),
|
||||
window,
|
||||
cx,
|
||||
Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
|
||||
workspace.focus_panel::<ProjectPanel>(window, cx);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let subscription = Rc::new(RefCell::new(None));
|
||||
subscription.replace(Some(cx.observe_in(&cx.entity(), window, {
|
||||
let subscription = subscription.clone();
|
||||
let repo_url = repo_url.clone();
|
||||
move |_, workspace_entity, window, cx| {
|
||||
if window.is_window_active() && subscription.take().is_some() {
|
||||
clone_and_open(
|
||||
repo_url.clone(),
|
||||
workspace_entity.downgrade(),
|
||||
window,
|
||||
cx,
|
||||
Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
|
||||
workspace.focus_panel::<ProjectPanel>(window, cx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
})));
|
||||
});
|
||||
}
|
||||
OpenRequestKind::GitCommit { sha } => {
|
||||
cx.spawn(async move |cx| {
|
||||
let paths_with_position =
|
||||
|
||||
@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use ui::SharedString;
|
||||
use util::ResultExt;
|
||||
use util::paths::PathWithPosition;
|
||||
use workspace::PathList;
|
||||
@@ -58,6 +59,9 @@ pub enum OpenRequestKind {
|
||||
/// `None` opens settings without navigating to a specific path.
|
||||
setting_path: Option<String>,
|
||||
},
|
||||
GitClone {
|
||||
repo_url: SharedString,
|
||||
},
|
||||
GitCommit {
|
||||
sha: String,
|
||||
},
|
||||
@@ -113,6 +117,8 @@ impl OpenRequest {
|
||||
this.kind = Some(OpenRequestKind::Setting {
|
||||
setting_path: Some(setting_path.to_string()),
|
||||
});
|
||||
} else if let Some(clone_path) = url.strip_prefix("zed://git/clone") {
|
||||
this.parse_git_clone_url(clone_path)?
|
||||
} else if let Some(commit_path) = url.strip_prefix("zed://git/commit/") {
|
||||
this.parse_git_commit_url(commit_path)?
|
||||
} else if url.starts_with("ssh://") {
|
||||
@@ -143,6 +149,26 @@ impl OpenRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> {
|
||||
// Format: /?repo=<url> or ?repo=<url>
|
||||
let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path);
|
||||
|
||||
let query = clone_path
|
||||
.strip_prefix('?')
|
||||
.context("invalid git clone url: missing query string")?;
|
||||
|
||||
let repo_url = url::form_urlencoded::parse(query.as_bytes())
|
||||
.find_map(|(key, value)| (key == "repo").then_some(value))
|
||||
.filter(|s| !s.is_empty())
|
||||
.context("invalid git clone url: missing repo query parameter")?
|
||||
.to_string()
|
||||
.into();
|
||||
|
||||
self.kind = Some(OpenRequestKind::GitClone { repo_url });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_git_commit_url(&mut self, commit_path: &str) -> Result<()> {
|
||||
// Format: <sha>?repo=<path>
|
||||
let (sha, query) = commit_path
|
||||
@@ -1087,4 +1113,80 @@ mod tests {
|
||||
|
||||
assert!(!errored_reuse);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_parse_git_clone_url(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
|
||||
let request = cx.update(|cx| {
|
||||
OpenRequest::parse(
|
||||
RawOpenRequest {
|
||||
urls: vec![
|
||||
"zed://git/clone/?repo=https://github.com/zed-industries/zed.git".into(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
match request.kind {
|
||||
Some(OpenRequestKind::GitClone { repo_url }) => {
|
||||
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
|
||||
}
|
||||
_ => panic!("Expected GitClone kind"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_parse_git_clone_url_without_slash(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
|
||||
let request = cx.update(|cx| {
|
||||
OpenRequest::parse(
|
||||
RawOpenRequest {
|
||||
urls: vec![
|
||||
"zed://git/clone?repo=https://github.com/zed-industries/zed.git".into(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
match request.kind {
|
||||
Some(OpenRequestKind::GitClone { repo_url }) => {
|
||||
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
|
||||
}
|
||||
_ => panic!("Expected GitClone kind"),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_parse_git_clone_url_with_encoding(cx: &mut TestAppContext) {
|
||||
let _app_state = init_test(cx);
|
||||
|
||||
let request = cx.update(|cx| {
|
||||
OpenRequest::parse(
|
||||
RawOpenRequest {
|
||||
urls: vec![
|
||||
"zed://git/clone/?repo=https%3A%2F%2Fgithub.com%2Fzed-industries%2Fzed.git"
|
||||
.into(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
match request.kind {
|
||||
Some(OpenRequestKind::GitClone { repo_url }) => {
|
||||
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
|
||||
}
|
||||
_ => panic!("Expected GitClone kind"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,17 @@ mv Cargo.toml.backup Cargo.toml
|
||||
popd
|
||||
echo "Bundled ${app_path}"
|
||||
|
||||
# DocumentTypes.plist references CFBundleTypeIconFile "Document", so the bundle must contain Document.icns.
|
||||
# We use the app icon as a placeholder document icon for now.
|
||||
document_icon_source="crates/zed/resources/Document.icns"
|
||||
document_icon_target="${app_path}/Contents/Resources/Document.icns"
|
||||
if [[ -f "${document_icon_source}" ]]; then
|
||||
mkdir -p "$(dirname "${document_icon_target}")"
|
||||
cp "${document_icon_source}" "${document_icon_target}"
|
||||
else
|
||||
echo "cargo::warning=Missing ${document_icon_source}; macOS document icons may not appear in Finder."
|
||||
fi
|
||||
|
||||
if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n "${APPLE_NOTARIZATION_KEY:-}" && -n "${APPLE_NOTARIZATION_KEY_ID:-}" && -n "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then
|
||||
can_code_sign=true
|
||||
|
||||
|
||||
81
script/verify-macos-document-icon
Executable file
81
script/verify-macos-document-icon
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
script/verify-macos-document-icon /path/to/Zed.app
|
||||
|
||||
Verifies that the given macOS app bundle's Info.plist references a document icon
|
||||
named "Document" and that the corresponding icon file exists in the bundle.
|
||||
|
||||
Specifically checks:
|
||||
- CFBundleDocumentTypes[*].CFBundleTypeIconFile includes "Document"
|
||||
- Contents/Resources/Document.icns exists
|
||||
|
||||
Exit codes:
|
||||
0 - success
|
||||
1 - verification failed
|
||||
2 - invalid usage / missing prerequisites
|
||||
USAGE
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
app_path="$1"
|
||||
|
||||
if [[ ! -d "${app_path}" ]]; then
|
||||
fail "app bundle not found: ${app_path}"
|
||||
fi
|
||||
|
||||
info_plist="${app_path}/Contents/Info.plist"
|
||||
if [[ ! -f "${info_plist}" ]]; then
|
||||
fail "missing Info.plist: ${info_plist}"
|
||||
fi
|
||||
|
||||
if ! command -v plutil >/dev/null 2>&1; then
|
||||
fail "plutil not found (required on macOS to read Info.plist)"
|
||||
fi
|
||||
|
||||
# Convert to JSON for robust parsing. plutil outputs JSON to stdout in this mode.
|
||||
info_json="$(plutil -convert json -o - "${info_plist}")"
|
||||
|
||||
# Check that CFBundleDocumentTypes exists and that at least one entry references "Document".
|
||||
# We use Python for JSON parsing; macOS ships with Python 3 on many setups, but not all.
|
||||
# If python3 isn't available, fall back to a simpler grep-based check.
|
||||
has_document_icon_ref="false"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
has_document_icon_ref="$(python3 -c "import json,sys; d=json.load(sys.stdin); types=d.get('CFBundleDocumentTypes', []); vals=[t.get('CFBundleTypeIconFile') for t in types if isinstance(t, dict)]; print('true' if 'Document' in vals else 'false')" <<<"${info_json}")"
|
||||
else
|
||||
# This is a best-effort fallback. It may produce false negatives if the JSON formatting differs.
|
||||
if echo "${info_json}" | grep -q '"CFBundleTypeIconFile"[[:space:]]*:[[:space:]]*"Document"'; then
|
||||
has_document_icon_ref="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${has_document_icon_ref}" != "true" ]]; then
|
||||
echo "Verification failed for: ${app_path}" >&2
|
||||
echo "Expected Info.plist to reference CFBundleTypeIconFile \"Document\" in CFBundleDocumentTypes." >&2
|
||||
echo "Tip: This bundle may be missing DocumentTypes.plist extensions or may have different icon naming." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
document_icon_path="${app_path}/Contents/Resources/Document.icns"
|
||||
if [[ ! -f "${document_icon_path}" ]]; then
|
||||
echo "Verification failed for: ${app_path}" >&2
|
||||
echo "Expected document icon to exist: ${document_icon_path}" >&2
|
||||
echo "Tip: The bundle script should copy crates/zed/resources/Document.icns into Contents/Resources/Document.icns." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: ${app_path}"
|
||||
echo " - Info.plist references CFBundleTypeIconFile \"Document\""
|
||||
echo " - Found ${document_icon_path}"
|
||||
@@ -109,19 +109,6 @@ fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJo
|
||||
}
|
||||
|
||||
fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob {
|
||||
fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
|
||||
let step = named::uses(
|
||||
"actions",
|
||||
"create-github-app-token",
|
||||
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
|
||||
)
|
||||
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
|
||||
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
|
||||
.id("get-app-token");
|
||||
let output = StepOutput::new(&step, "token");
|
||||
(step, output)
|
||||
}
|
||||
|
||||
fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step<Run> {
|
||||
named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token))
|
||||
}
|
||||
@@ -148,7 +135,7 @@ fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob
|
||||
.add_env(("GITHUB_TOKEN", token))
|
||||
}
|
||||
|
||||
let (authenticate, token) = authenticate_as_zippy();
|
||||
let (authenticate, token) = steps::authenticate_as_zippy();
|
||||
|
||||
named::job(
|
||||
Job::default()
|
||||
|
||||
@@ -3,7 +3,7 @@ use gh_workflow::*;
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{self, NamedJob, named},
|
||||
vars::{self, StepOutput, WorkflowInput},
|
||||
vars::{StepOutput, WorkflowInput},
|
||||
};
|
||||
|
||||
pub fn cherry_pick() -> Workflow {
|
||||
@@ -29,19 +29,6 @@ fn run_cherry_pick(
|
||||
commit: &WorkflowInput,
|
||||
channel: &WorkflowInput,
|
||||
) -> NamedJob {
|
||||
fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
|
||||
let step = named::uses(
|
||||
"actions",
|
||||
"create-github-app-token",
|
||||
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
|
||||
) // v2
|
||||
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
|
||||
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
|
||||
.id("get-app-token");
|
||||
let output = StepOutput::new(&step, "token");
|
||||
(step, output)
|
||||
}
|
||||
|
||||
fn cherry_pick(
|
||||
branch: &WorkflowInput,
|
||||
commit: &WorkflowInput,
|
||||
@@ -54,7 +41,7 @@ fn run_cherry_pick(
|
||||
.add_env(("GITHUB_TOKEN", token))
|
||||
}
|
||||
|
||||
let (authenticate, token) = authenticate_as_zippy();
|
||||
let (authenticate, token) = steps::authenticate_as_zippy();
|
||||
|
||||
named::job(
|
||||
Job::default()
|
||||
|
||||
@@ -97,17 +97,20 @@ pub(crate) fn create_sentry_release() -> Step<Use> {
|
||||
}
|
||||
|
||||
fn auto_release_preview(deps: &[&NamedJob; 1]) -> NamedJob {
|
||||
let (authenticate, token) = steps::authenticate_as_zippy();
|
||||
|
||||
named::job(
|
||||
dependant_job(deps)
|
||||
.runs_on(runners::LINUX_SMALL)
|
||||
.cond(Expression::new(indoc::indoc!(
|
||||
r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
|
||||
)))
|
||||
.add_step(authenticate)
|
||||
.add_step(
|
||||
steps::script(
|
||||
r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
|
||||
)
|
||||
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
|
||||
.add_env(("GITHUB_TOKEN", &token)),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -354,3 +354,16 @@ pub fn trigger_autofix(run_clippy: bool) -> Step<Run> {
|
||||
))
|
||||
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
|
||||
}
|
||||
|
||||
pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
|
||||
let step = named::uses(
|
||||
"actions",
|
||||
"create-github-app-token",
|
||||
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
|
||||
)
|
||||
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
|
||||
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
|
||||
.id("get-app-token");
|
||||
let output = StepOutput::new(&step, "token");
|
||||
(step, output)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user