Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ docs/old-promo/
# 宣传录屏 / 素材根目录(GB 级二进制,绝不入版本库)
video-materials/

# Local OpenLess development diagnostics and generated release candidates.
# Keep these available on the workstation, but never include them in a PR.
/.codegraph/
/artifacts/
/baseline-*.log
/cargo-*.log
/docs/notes/

# 派生产物(兜底):项目曾出现 promo-openless-v2/node_modules 等遗漏,
# 这里全局通配,避免某子目录漏配 .gitignore 时把 build artifact 推进 PR。
node_modules/
Expand Down Expand Up @@ -111,4 +119,4 @@ docs/windows-lifecycle-tracking/
docs/windows-ui-tracking/

# 用于本地语音推理的参考文件。
CapsWriter
CapsWriter
2 changes: 2 additions & 0 deletions openless-all/app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ dist/
.env
.vite/
.artifacts/
# Local shell error capture accidentally written by an invalid Tauri command.
/+

# Tauri
src-tauri/target/
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev:visible": "node scripts/tauri-dev-visible.mjs",
"tauri:android:init": "tauri android init",
"tauri:android:dev": "tauri android dev",
"tauri:android:build": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi",
Expand Down
12 changes: 12 additions & 0 deletions openless-all/app/scripts/tauri-dev-visible.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// 开发/自动化验收专用:OpenLess 正式产品默认最小化到托盘,
// 但端到端测试需要一个可被系统自动化识别的主窗口。
import { spawn } from 'node:child_process';

const command = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const child = spawn(command, ['run', 'tauri', '--', 'dev'], {
stdio: 'inherit',
shell: process.platform === 'win32',
env: { ...process.env, OPENLESS_SHOW_MAIN_ON_START: '1' },
});

child.on('exit', code => process.exit(code ?? 1));
3 changes: 2 additions & 1 deletion openless-all/app/src-tauri/src/commands/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,13 @@ fn apply_retranscription(
mod retranscribe_tests {
use super::apply_retranscription;
use crate::coordinator::AsrCallLabel;
use crate::types::{DictationSession, InsertStatus, PolishMode};
use crate::types::{DictationSession, HistorySource, InsertStatus, PolishMode};

fn failed_entry() -> DictationSession {
DictationSession {
id: "s1".into(),
created_at: "2026-07-15T00:00:00Z".into(),
source: HistorySource::Voice,
raw_transcript: String::new(),
final_text: String::new(),
mode: PolishMode::Light,
Expand Down
135 changes: 135 additions & 0 deletions openless-all/app/src-tauri/src/commands/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub fn set_dictation_hotkey(
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_dictation_less_computer_hotkey_overlap(&binding, less_computer)?;
}
reject_existing_selection_polish_hotkey_overlap(&binding, &prefs)?;
prefs.dictation_hotkey = binding;
sync_dictation_hotkey_legacy_fields(&mut prefs);
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -55,6 +56,7 @@ pub fn set_translation_hotkey(
if let Some(less_computer) = previous.coding_agent_voice_hotkey.as_ref() {
reject_translation_less_computer_hotkey_overlap(&binding, less_computer)?;
}
reject_existing_selection_polish_hotkey_overlap(&binding, &previous)?;
let mut prefs = previous.clone();
prefs.translation_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -93,6 +95,7 @@ pub fn set_switch_style_hotkey(
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_less_computer_switch_style_hotkey_overlap(less_computer, binding)?;
}
reject_existing_selection_polish_hotkey_overlap(binding, &prefs)?;
}
prefs.switch_style_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -124,13 +127,46 @@ pub fn set_open_app_hotkey(
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_less_computer_open_app_hotkey_overlap(less_computer, binding)?;
}
reject_existing_selection_polish_hotkey_overlap(binding, &prefs)?;
}
prefs.open_app_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
coord.update_open_app_hotkey_binding();
Ok(())
}

/// Set the Selection Polish global shortcut. The new binding is persisted first
/// so the coordinator sees it during registration; a registration failure
/// restores the exact previous preferences and listener state before returning.
#[tauri::command]
pub fn set_selection_polish_hotkey(
coord: CoordinatorState<'_>,
binding: Option<ShortcutBinding>,
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
reject_bare_shift_dictation_shortcut(binding)?;
}
let previous = coord.prefs().get();
if let Some(binding) = binding.as_ref() {
reject_selection_polish_hotkey_collisions(binding, &previous)?;
}
let mut next = previous.clone();
next.selection_polish_hotkey = binding;
coord.prefs().set(next).map_err(|e| e.to_string())?;
if let Err(error) = coord.try_update_selection_polish_hotkey_binding() {
if let Err(rollback_error) = coord.prefs().set(previous) {
return Err(format!(
"{error}; additionally failed to restore previous Selection Polish shortcut: {rollback_error}"
));
}
coord.update_selection_polish_hotkey_binding();
return Err(error);
}
Ok(())
}

fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), String> {
if binding.modifiers.is_empty()
&& (binding.primary.eq_ignore_ascii_case("shift")
Expand Down Expand Up @@ -174,6 +210,7 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_dictation_less_computer_hotkey_overlap(&shortcut, less_computer)?;
}
reject_existing_selection_polish_hotkey_overlap(&shortcut, &prefs)?;
prefs.custom_combo_hotkey = Some(binding);
prefs.dictation_hotkey = shortcut;
sync_dictation_hotkey_legacy_fields(&mut prefs);
Expand Down Expand Up @@ -275,6 +312,64 @@ pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), St
if let (Some(switch_style), Some(open_app)) = (switch_style, open_app) {
reject_switch_style_open_app_hotkey_overlap(switch_style, open_app)?;
}
if let Some(selection_polish) = prefs.selection_polish_hotkey.as_ref() {
reject_selection_polish_hotkey_collisions(selection_polish, prefs)?;
}
Ok(())
}

pub(crate) fn reject_selection_polish_hotkey_collisions(
selection_polish: &ShortcutBinding,
prefs: &UserPreferences,
) -> Result<(), String> {
reject_hotkey_overlap(
selection_polish,
&prefs.dictation_hotkey,
"选区润色快捷键不能和听写快捷键相同",
)?;
reject_hotkey_overlap(
selection_polish,
&prefs.translation_hotkey,
"选区润色快捷键不能和翻译快捷键相同",
)?;
if let Some(qa) = prefs.qa_hotkey.as_ref() {
reject_hotkey_overlap(selection_polish, qa, "选区润色快捷键不能和 QA 快捷键相同")?;
}
if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() {
reject_hotkey_overlap(
selection_polish,
switch_style,
"选区润色快捷键不能和切换风格快捷键相同",
)?;
}
if let Some(open_app) = prefs.open_app_hotkey.as_ref() {
reject_hotkey_overlap(
selection_polish,
open_app,
"选区润色快捷键不能和打开应用快捷键相同",
)?;
}
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_hotkey_overlap(
selection_polish,
less_computer,
"选区润色快捷键不能和 Less Computer 快捷键相同",
)?;
}
Ok(())
}

pub(crate) fn reject_existing_selection_polish_hotkey_overlap(
binding: &ShortcutBinding,
prefs: &UserPreferences,
) -> Result<(), String> {
if let Some(selection_polish) = prefs.selection_polish_hotkey.as_ref() {
reject_hotkey_overlap(
binding,
selection_polish,
"该快捷键不能和选区润色快捷键相同",
)?;
}
Ok(())
}

Expand All @@ -291,6 +386,11 @@ pub(crate) fn reject_non_dictation_side_specific_shortcuts(
if let Some(binding) = prefs.open_app_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
if let Some(binding) = prefs.selection_polish_hotkey.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
reject_bare_shift_dictation_shortcut(binding)?;
}
if let Some(binding) = prefs.coding_agent_voice_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
Expand Down Expand Up @@ -481,6 +581,29 @@ mod tests {
assert!(reject_hotkey_collisions(&prefs).is_ok());
}

#[test]
fn selection_polish_hotkey_collides_with_existing_shortcuts() {
let binding = key("RightControl");
let prefs = UserPreferences {
dictation_hotkey: binding.clone(),
selection_polish_hotkey: Some(binding),
..Default::default()
};
assert!(reject_hotkey_collisions(&prefs).is_err());
}

#[test]
fn existing_selection_polish_hotkey_rejects_another_action_binding() {
let selection = key("RightControl");
let prefs = UserPreferences {
selection_polish_hotkey: Some(selection.clone()),
..Default::default()
};

assert!(reject_existing_selection_polish_hotkey_overlap(&selection, &prefs).is_err());
assert!(reject_existing_selection_polish_hotkey_overlap(&key("P"), &prefs).is_ok());
}

#[test]
fn side_specific_dictation_overlaps_generic_qa_hotkey() {
let mut prefs = UserPreferences {
Expand Down Expand Up @@ -526,6 +649,18 @@ mod tests {
assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_err());
}

#[test]
fn rejects_side_specific_selection_polish_hotkey_on_save() {
let prefs = UserPreferences {
selection_polish_hotkey: Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-right".into()],
}),
..Default::default()
};
assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_err());
}

#[test]
fn accepts_side_specific_dictation_hotkey_on_save() {
let prefs = UserPreferences {
Expand Down
14 changes: 14 additions & 0 deletions openless-all/app/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ mod remote_input;
mod settings;
#[cfg(not(mobile))]
mod sherpa_asr;
#[cfg(all(not(mobile), debug_assertions))]
mod selection_polish;
#[cfg(not(mobile))]
mod selection_polish_preview;
mod style_packs;

pub use credentials::*;
Expand All @@ -110,6 +114,10 @@ pub use settings::*;
#[cfg(not(mobile))]
#[allow(unused_imports)]
pub use sherpa_asr::*;
#[cfg(all(not(mobile), debug_assertions))]
pub use selection_polish::*;
#[cfg(not(mobile))]
pub use selection_polish_preview::*;
pub use style_packs::*;

pub(crate) type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
Expand Down Expand Up @@ -650,6 +658,8 @@ mod tests {
*self.open_app_refreshes.lock().unwrap() += 1;
}

fn refresh_selection_polish_hotkey(&self) {}

fn refresh_coding_agent_hotkey(&self) {
*self.coding_agent_refreshes.lock().unwrap() += 1;
}
Expand Down Expand Up @@ -794,6 +804,10 @@ mod tests {
primary: "RightControl".to_string(),
modifiers: vec![],
}),
// This fixture deliberately assigns Right Control to Less Computer.
// Keep selection polish disabled so the test exercises the intended
// independent refresh paths.
selection_polish_hotkey: None,
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::Custom,
mode: HotkeyMode::Hold,
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/commands/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub fn set_qa_hotkey(
if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() {
reject_qa_less_computer_hotkey_overlap(binding, less_computer)?;
}
reject_existing_selection_polish_hotkey_overlap(binding, &prefs)?;
}
prefs.qa_hotkey = binding;
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
Expand Down
7 changes: 7 additions & 0 deletions openless-all/app/src-tauri/src/commands/selection_polish.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use super::*;

/// Development-only entry point for exercising the selection-polish workflow.
#[tauri::command]
pub async fn run_selection_polish_for_dev(coord: CoordinatorState<'_>) -> Result<(), String> {
coord.trigger_selection_polish_for_dev().await
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use super::*;
use crate::coordinator::selection_polish::SelectionPolishPreviewPayload;

#[tauri::command]
pub fn get_selection_polish_preview(
coord: CoordinatorState<'_>,
) -> Option<SelectionPolishPreviewPayload> {
coord.selection_polish_preview()
}

#[tauri::command]
pub fn confirm_selection_polish_preview(
coord: CoordinatorState<'_>,
text: String,
) -> Result<(), String> {
coord.confirm_selection_polish_preview(text)
}

#[tauri::command]
pub fn cancel_selection_polish_preview(coord: CoordinatorState<'_>) {
coord.cancel_selection_polish_preview();
}
Loading